Blocking vs Non-Blocking Code in Node.js
Node.js is built around a single idea: do not wait. Understanding why that matters — and how Node.js achieves it — is essential before you write any serious server-side code.
This article explains blocking and non-blocking code, shows why the distinction matters for server performance, and walks through real examples using file reads and database calls.
What Blocking Code Means
Blocking code is code that stops everything else from running until it finishes.
When a line of code blocks, the program halts at that line and waits — no other code runs, no other requests are handled, nothing moves forward until the operation completes and returns a result.
Here is the simplest mental model: imagine a single cashier at a counter. A customer walks up and asks for something that takes five minutes to prepare. The cashier stands still, waits, and every other customer in line just waits too. No one else gets served until that one task is done.
That is blocking.
In code, this looks like synchronous file reading in Node.js:
const fs = require('fs');
const data = fs.readFileSync('/path/to/file.txt', 'utf8');
console.log(data);
console.log('This line runs only after the file is fully read');
The readFileSync call tells Node.js to stop and wait for the entire file to be read off disk before moving to the next line. While that read happens, no other work can be done.
What Non-Blocking Code Means
Non-blocking code kicks off an operation and immediately moves on. When the operation eventually finishes, a callback or a promise handles the result.
The cashier analogy again: this time, the cashier takes the customer's order, hands it to the kitchen, and immediately turns to serve the next person. When the kitchen finishes, they call out the order and the first customer picks it up. The cashier never stood still.
That is non-blocking.
In code, this looks like asynchronous file reading:
const fs = require('fs');
fs.readFile('/path/to/file.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});
console.log('This line runs immediately, before the file is read');
The readFile call starts the operation and returns instantly. Node.js registers the callback and moves on to the next line. When the file read completes (handled by the OS in the background), Node.js picks up the callback and runs it.
Why Blocking Slows Servers
Node.js runs on a single thread. There is one event loop, and it handles all incoming requests.
When blocking code runs on that thread, the entire server freezes. Every other incoming request has to wait — not because the server is busy computing, but because it is sitting idle, waiting for something like a disk read or a network call to finish.
Consider a web server that handles 1,000 requests per second. If one request triggers a blocking file read that takes 200ms, every one of those 1,000 requests piles up behind it. Users across the board experience that delay — not just the one who triggered the file read.
This is why blocking in Node.js is more damaging than blocking in languages that use one thread per request. In those models, one blocked thread does not affect others. In Node.js, one blocked operation affects every user connected to the server at that moment.
The rule is simple: never block the event loop.
Async Operations in Node.js
Node.js provides three main patterns for writing non-blocking code.
Callbacks
The original pattern. You pass a function as an argument, and it gets called when the operation is done.
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) {
console.error('Error reading file:', err);
return;
}
console.log(data);
});
Callbacks work, but they nest deeply when multiple async operations depend on each other — a problem commonly called callback hell.
Promises
Promises represent a value that will be available in the future. They chain cleanly and handle errors with .catch().
const fs = require('fs').promises;
fs.readFile('data.txt', 'utf8')
.then(data => console.log(data))
.catch(err => console.error('Error:', err));
Async/Await
The cleanest syntax. async/await is built on top of promises but reads like synchronous code — without blocking the event loop.
const fs = require('fs').promises;
async function readData() {
try {
const data = await fs.readFile('data.txt', 'utf8');
console.log(data);
} catch (err) {
console.error('Error:', err);
}
}
readData();
The await keyword pauses execution inside the async function, but it does not block the event loop. Node.js is free to handle other work while the file read completes.
Real-World Examples
File Read: Blocking vs Non-Blocking
This is the most direct comparison.
Blocking:
const fs = require('fs');
function handleRequest() {
const config = fs.readFileSync('config.json', 'utf8'); // server halts here
const parsed = JSON.parse(config);
return parsed;
}
If config.json is large or the disk is slow, every request to this server waits.
Non-blocking:
const fs = require('fs').promises;
async function handleRequest() {
const config = await fs.readFile('config.json', 'utf8');
const parsed = JSON.parse(config);
return parsed;
}
Same result. The server reads the file, but other requests continue being handled while it waits for the read to finish.
Database Calls
Database operations are network calls — they take time. A blocking database call would freeze the entire server for every query.
What you should never do:
// Hypothetical blocking DB call
const user = db.findUserSync(userId); // blocks the event loop
res.json(user);
What you should do:
// Non-blocking with async/await
async function getUser(req, res) {
try {
const user = await db.findUser(req.params.id);
if (!user) return res.status(404).json({ message: 'User not found' });
res.status(200).json(user);
} catch (err) {
res.status(500).json({ message: 'Server error' });
}
}
The await lets Node.js handle other requests while the database query runs. The server stays responsive the entire time.
Handling Multiple Async Operations
When you need results from several independent async operations, running them sequentially wastes time.
Sequential (slow):
async function getDashboard(userId) {
const user = await db.getUser(userId); // wait
const posts = await db.getPosts(userId); // then wait
const stats = await db.getStats(userId); // then wait
return { user, posts, stats };
}
Parallel (fast):
async function getDashboard(userId) {
const [user, posts, stats] = await Promise.all([
db.getUser(userId),
db.getPosts(userId),
db.getStats(userId),
]);
return { user, posts, stats };
}
Promise.all fires all three queries at the same time and waits for all of them to finish. The total wait time is the duration of the slowest query — not the sum of all three.
Blocking vs Non-Blocking: Side by Side
| Blocking | Non-Blocking | |
|---|---|---|
| Execution | Waits for operation to finish | Moves on immediately |
| Event loop | Freezes during the operation | Stays free |
| Other requests | Queued, cannot be processed | Handled normally |
| Code pattern | readFileSync, synchronous calls |
Callbacks, Promises, async/await |
| When to use | Startup scripts, CLI tools | HTTP servers, APIs, any production server |
When Blocking Is Acceptable
Not everything needs to be async. There are cases where blocking code is perfectly fine:
Server startup: reading a config file once before the server starts accepting requests does not affect live traffic.
CLI scripts: a script that runs once and exits does not have an event loop to protect.
Build tools: tools like webpack or compilers are not serving concurrent users.
The rule is about servers. If you are writing code that handles concurrent requests, never block the event loop.