Blocking vs Non-Blocking Code in Node.js
One of the first things developers notice when they start building seriously with Node.js is that it handles concurrency differently from what they might expect. There are no threads to spin up, no worker pools to configure for basic I/O, and yet a single Node.js process can handle thousands of simultaneous requests without grinding to a halt.
That behavior comes from one underlying idea: non-blocking I/O. Understanding it is not optional if you want to write Node.js that actually performs well. This article explains what blocking and non-blocking mean, why the distinction matters on a server, and how Node.js gives you the tools to work in the non-blocking model.
What Blocking Code Means
Blocking code is code that halts execution until an operation completes. While the operation runs, nothing else can happen on that thread. The program waits.
The most common source of blocking in server applications is I/O — input and output operations. Reading a file from disk, querying a database, calling an external API. These operations take time measured in milliseconds or more, and that time is not spent computing. It is spent waiting for an external system to respond.
In a blocking model, that wait is dead time. The thread is held open, consuming memory and CPU context, doing nothing useful while the disk spins or the database executes a query.
Here is what blocking looks like in Node.js using the synchronous file system API:
const fs = require('fs');
console.log('Reading file...');
// This line blocks — nothing else runs until the file is fully read
const contents = fs.readFileSync('data.txt', 'utf8');
console.log('File contents:', contents);
console.log('This line only runs after the file is completely read');
readFileSync does not return until the entire file is in memory. If the file is large, or if the disk is slow, the program sits there waiting. Any other work — processing another request, running a timer, handling a user action — is queued behind it.
What Non-Blocking Code Means
Non-blocking code initiates an operation and returns immediately, without waiting for the result. The operation continues in the background. When it finishes, a callback, a Promise, or an event signals that the result is ready and resumes the relevant work.
The thread that started the operation is free to do other things in the meantime.
Here is the non-blocking version of the same file read:
const fs = require('fs');
console.log('Reading file...');
// This line does NOT block — it starts the read and returns immediately
fs.readFile('data.txt', 'utf8', (err, contents) => {
// This runs later, when the file is ready
if (err) throw err;
console.log('File contents:', contents);
});
// This runs immediately, before the file is done reading
console.log('This line runs right away');
Output:
Reading file...
This line runs right away
File contents: (printed when the OS signals the read is complete)
The file read is in progress, but the program did not stop to wait for it. It continued executing, and the callback ran when the data was available. Non-blocking code is about making productive use of wait time rather than surrendering it.
The Waiting Room Analogy
Imagine a doctor's office with a single doctor.
In the blocking model, the doctor calls in one patient, examines them, waits for lab results to come back, writes up notes, and only then calls the next patient. Every other patient in the waiting room sits idle for the full duration of each appointment — including the waiting-for-lab-results part where the doctor is not even actively engaged.
In the non-blocking model, the doctor calls in a patient, does the examination, sends off the lab work, and while those results are being processed by the lab, calls in the next patient. When the lab results arrive, the doctor circles back to the first patient. Multiple patients are being actively processed simultaneously, even though there is only one doctor.
Node.js is the second doctor. There is one main thread, but it never sits waiting for external operations to complete. It dispatches the work and keeps moving.
Why Blocking Slows Servers
On a personal script, blocking code is often fine. If you are writing a one-off utility that reads a file and processes it, there is no concurrency requirement. The script runs, it finishes, it exits.
On a server, the situation is completely different. A server handles many requests concurrently. Users do not wait for each other to finish before sending their requests. They arrive in parallel, and the server is expected to handle all of them.
With blocking code, requests pile up behind each other:
Blocking Server — Single Thread:
─────────────────────────────────────────────────────────────────
Time → 0ms 50ms 100ms 150ms 200ms 250ms 300ms
│ │ │ │ │ │ │
Thread: [Request 1: wait for DB (80ms)][Request 2: wait for DB (80ms)][Request 3...]
←─────────────────────────────────────────────────────────────────────────
Request 3 starts at 160ms. Request 10 starts at 720ms. User is waiting.
Each request occupies the thread for its full duration, including all idle wait time. Response time grows linearly with load. A server that responds in 80ms under no load responds in 800ms when 10 requests arrive simultaneously — not because anything got harder to compute, but because everything is queued behind blocking waits.
This is not a theoretical concern. It is why PHP applications historically needed aggressive caching and connection pooling just to survive traffic spikes, and why "C10K" — handling 10,000 concurrent connections — was considered a difficult engineering problem in the early 2000s.
How Non-Blocking Execution Works
Non-Blocking Server — Node.js Event Loop:
──────────────────────────────────────────────────────────────────────
Time → 0ms 80ms
│ │
Thread: [Req 1][Req 2][Req 3][Req 4][Req 5]...[DB result 1][DB result 2][DB result 3]
│ │ │ │ │ │
│ │ │ └──► Send └──► Send └──► Send
│ │ │ response response response
│ │ └──► DB query dispatched (takes ~80ms)
│ └──► DB query dispatched
└──► DB query dispatched
All requests dispatched in the first few milliseconds.
DB results arrive and responses are sent as each completes.
No request waits for another. Total time ≈ 80ms regardless of request count.
Node.js dispatches every I/O operation to the operating system and moves on. The event loop — the engine at Node.js's core — continuously checks whether any pending operations have completed. When they have, it picks up the associated callback and runs it.
The thread is never idle. It is either dispatching work, or processing completed results. The waiting is done by the OS, not by Node.js.
Async Operations in Node.js
Node.js provides three main patterns for working with asynchronous code. They are not mutually exclusive — modern codebases use all three depending on the API and context.
Callbacks
The original Node.js pattern. You pass a function to an async operation, and that function is called when the operation completes. By convention, the first argument is an error (or null if there was none), and subsequent arguments are results.
const fs = require('fs');
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('Failed to read config:', err.message);
return;
}
const config = JSON.parse(data);
console.log('Config loaded:', config);
});
Callbacks are simple but become difficult to manage when operations depend on each other — reading a file, then parsing it, then writing a result. Nested callbacks grow into what is commonly called "callback hell."
Promises
Promises represent an operation that has not completed yet but will produce a value (or an error) in the future. They chain with .then() and handle errors with .catch(), making sequential async operations more readable.
const fs = require('fs').promises;
fs.readFile('config.json', 'utf8')
.then((data) => {
const config = JSON.parse(data);
return fs.writeFile('config.backup.json', JSON.stringify(config, null, 2));
})
.then(() => {
console.log('Backup written');
})
.catch((err) => {
console.error('Something went wrong:', err.message);
});
async/await
async/await is syntax built on top of Promises. It lets you write asynchronous code that looks synchronous — sequential, top-to-bottom, with error handling through standard try/catch. It is the most readable pattern for most use cases.
const fs = require('fs').promises;
async function backupConfig() {
try {
const data = await fs.readFile('config.json', 'utf8');
const config = JSON.parse(data);
await fs.writeFile('config.backup.json', JSON.stringify(config, null, 2));
console.log('Backup written');
} catch (err) {
console.error('Something went wrong:', err.message);
}
}
backupConfig();
Despite looking synchronous, await does not block the event loop. It suspends the current async function and yields control back to the event loop. Other work can run while the awaited operation completes. When the operation finishes, the function resumes from where it left off.
This is the key point developers sometimes miss: await pauses the function, not the thread.
Real-World Examples
File Read
A common task in any server: reading a configuration file, a template, or a data file on startup or per request.
const fs = require('fs').promises;
const path = require('path');
// Blocking approach — do not do this in a server
function getConfigBlocking() {
const raw = require('fs').readFileSync(path.join(__dirname, 'config.json'), 'utf8');
return JSON.parse(raw);
}
// Non-blocking approach
async function getConfig() {
const raw = await fs.readFile(path.join(__dirname, 'config.json'), 'utf8');
return JSON.parse(raw);
}
In a server that handles many requests, calling readFileSync on every request means every incoming request waits for every file read to complete, sequentially. Using the async version, all those file reads can be in progress simultaneously and the server continues handling new requests in the meantime.
Database Calls
Database queries are among the slowest I/O operations in a typical web application. A query that takes 50ms to execute — fetching a user record, joining a few tables — is 50ms where a blocking server is doing nothing else.
const db = require('./db'); // hypothetical database client
// Blocking mental model (simplified) — server stalls on each query
async function getUserProfile(userId) {
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
const posts = await db.query('SELECT * FROM posts WHERE user_id = ?', [userId]);
return { user, posts };
}
The two queries here run sequentially — posts query waits for the users query to finish. This is a common accidental blocking pattern even in async code. The queries are not blocking the event loop (they are async), but they are blocking each other unnecessarily. The posts query does not depend on the users query result. They can run in parallel:
async function getUserProfile(userId) {
// Fire both queries simultaneously
const [user, posts] = await Promise.all([
db.query('SELECT * FROM users WHERE id = ?', [userId]),
db.query('SELECT * FROM posts WHERE user_id = ?', [userId]),
]);
return { user, posts };
}
Promise.all starts both operations immediately and waits for both to complete. If each query takes 50ms, the sequential version takes 100ms. The parallel version takes 50ms. The database does the same amount of work either way — the difference is in how efficiently Node.js uses the wait time.
External API Calls
When your server needs to aggregate data from multiple external services:
async function getDashboard(userId) {
// These are all independent — no reason to run them sequentially
const [profile, orders, notifications] = await Promise.all([
fetch(`https://api.example.com/users/${userId}`).then(r => r.json()),
fetch(`https://api.example.com/orders?user=${userId}`).then(r => r.json()),
fetch(`https://api.example.com/notifications?user=${userId}`).then(r => r.json()),
]);
return { profile, orders, notifications };
}
Three network requests. With sequential awaits, if each takes 200ms the total is 600ms. With Promise.all, the total is roughly 200ms — the time of the slowest request. The server is waiting the same wall-clock time either way, but only once instead of three times.
When Blocking Code Is Acceptable
Not all synchronous code is harmful. There are cases where it is the right choice.
Application startup. Reading configuration files, loading environment variables, or initializing state before the server starts listening is typically fine as blocking code. The server is not accepting requests yet, so there is nothing waiting on it.
// Fine at startup — no requests are being handled yet
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
const app = express();
app.listen(config.port);
CPU-bound computation. If you are doing heavy computation — parsing a large CSV, encrypting data, running an algorithm — that work is inherently synchronous. Making it async does not help because the CPU is actually busy; there is no idle wait time to reclaim. For genuinely CPU-bound work, the solution is worker threads or offloading to a separate process, not just adding await.
Short, one-off scripts. A deployment script, a data migration, a local development utility. If the script runs once and exits, concurrency is irrelevant.
The Core Rule
The practical rule for Node.js servers is straightforward: never block the event loop inside a request handler.
Any synchronous operation that takes meaningful time — file reads, crypto operations, JSON parsing of large payloads, complex loops — blocks every other request on the server for its duration. With non-blocking async operations, the event loop stays free, the server stays responsive, and concurrent requests are handled efficiently.
The Node.js standard library makes this easy by providing async versions of nearly every I/O operation. fs.readFile alongside fs.readFileSync. fs.promises.readFile for the Promise-based version. Virtually every database driver and HTTP client is built around Promises. The ecosystem strongly pushes you toward the non-blocking path.
The default should be async. Synchronous I/O inside a request handler should require a deliberate reason.
Summary
Blocking code stops execution and waits. Non-blocking code starts an operation, moves on, and resumes when the result is ready. On a personal script the difference is minor. On a server handling concurrent requests, it determines whether your application scales or collapses under load.
Node.js is built around non-blocking I/O. The event loop, libuv, and the async APIs in the standard library all exist to keep the main thread free while I/O operations complete in the background. Callbacks, Promises, and async/await are the three patterns for working with that model — with async/await being the most readable for most cases.
The performance gains from non-blocking code often require no extra infrastructure. Running two independent database queries in parallel instead of in sequence can cut response time in half. Running external API calls concurrently with Promise.all instead of sequentially can reduce latency by a factor of three or more. These are free improvements that come entirely from understanding how Node.js is designed to work.
The next logical step from here is understanding the Node.js event loop in more depth — how it processes the callback queue, microtask queue, and timer queue, and what determines the order in which callbacks run.
Comments
Join the discussionNo comments yet. Be the first to comment.
