Event Loop Blocking: Why Your setTimeout Callbacks Fire Late in Node.js
You schedule a callback with setTimeout(fn, 100) and expect it to run in roughly 100 milliseconds. Instead, it fires at 300ms, 500ms, or later β and your logs show no errors whatsoever. This is not a bug in Node.js. It is the event loop working exactly as designed, and your synchronous code is standing in the way.
Once you understand how the event loop phases interact with your timers, the late-firing mystery disappears β and you gain a clear mental model for diagnosing slowdowns before they reach production.
What You'll Learn
- How the Node.js event loop phases control when timer callbacks run
- What "blocking" actually means in a single-threaded runtime
- How to reproduce and measure blocking in your own code
- Practical patterns to break up CPU-heavy work without reaching for worker threads immediately
- Common pitfalls that cause timers to drift in real applications
Prerequisites
You should be comfortable reading basic JavaScript and have Node.js installed (any version from v16 onward works for the examples here). No third-party packages are needed.
The Event Loop in Plain Terms
Node.js runs JavaScript on a single thread. That thread processes one thing at a time, moving through a fixed sequence of phases on every "tick" of the event loop. Each phase drains its own queue before moving to the next.
The simplified phase order looks like this:
- Timers β runs callbacks whose
setTimeoutorsetIntervaldelay has expired - Pending callbacks β deferred I/O callbacks from the previous iteration
- Idle / prepare β internal use only
- Poll β retrieves new I/O events and executes their callbacks
- Check β runs
setImmediatecallbacks - Close callbacks β cleans up closed handles
Between phases, Node also drains the microtask queues (Promises and process.nextTick). This matters when you are stacking many resolved promises β they can delay the next phase just like synchronous code can.
Why Timers Are Not Precise
The delay you pass to setTimeout is a minimum delay, not a guarantee. The Node.js documentation says this explicitly. The timer fires only when the event loop reaches the timers phase and the delay has elapsed.
If your code is still executing synchronous work when the delay expires, Node cannot interrupt it. JavaScript is single-threaded. The event loop will not check for expired timers until the current synchronous frame finishes and control returns to the loop.
Here is a minimal reproduction:
const start = Date.now();
setTimeout(() => {
console.log(`Fired after ${Date.now() - start}ms`);
}, 100);
// Simulate blocking work
const end = Date.now() + 500; // block for 500ms
while (Date.now() < end) { /* busy wait */ }
console.log('Synchronous work done');Run this and you will see output like:
Synchronous work done
Fired after 501msThe timeout asked for 100ms. It got 501ms β because the synchronous while-loop held the thread for 500ms, preventing the event loop from checking anything.
What Counts as Blocking?
Obvious blocking is easy to spot: a tight while loop or a recursive Fibonacci function with no async steps. But several real-world patterns block the loop without looking suspicious at first glance.
Synchronous JSON parsing of large payloads
JSON.parse is synchronous. Parsing a 20MB API response on the main thread blocks the loop for the entire parse duration. For most payloads this is negligible, but it scales linearly with input size.
Large synchronous Array operations
Chaining .map(), .filter(), and .reduce() on an array with tens of thousands of items runs entirely on the main thread. Each individual call is fast, but chaining several passes over a large dataset adds up.
Crypto operations without the async API
Node's crypto module exposes both synchronous and asynchronous versions of most functions. crypto.pbkdf2Sync() blocks the loop for its entire duration. The async crypto.pbkdf2() does the same work in libuv's thread pool, leaving the main thread free.
Deeply nested process.nextTick or Promise chains
Microtasks run between every event loop phase, but they exhaust their entire queue before moving on. If you recursively schedule process.nextTick callbacks, Node will keep draining that queue indefinitely, starving I/O and timers.
// This will starve the event loop β never do this
function recurse() {
process.nextTick(recurse);
}
recurse();Measuring the Lag Yourself
Before you can fix a problem, you need to confirm it exists and size it. A simple heartbeat loop is the fastest way to measure event loop lag in a running process.
function measureLag(intervalMs = 100) {
let last = Date.now();
setInterval(() => {
const now = Date.now();
const lag = now - last - intervalMs;
if (lag > 10) {
console.warn(`Event loop lag: ${lag}ms`);
}
last = now;
}, intervalMs);
}
measureLag();Every tick of this interval should fire close to 100ms after the last. If lag spikes to 50ms or more, something is blocking the loop around that time. You now have a signal to investigate.
For production use, the perf_hooks module (built into Node) gives you more precise measurements via monitorEventLoopDelay:
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setTimeout(() => {
h.disable();
console.log(`Mean lag: ${h.mean / 1e6}ms`);
console.log(`Max lag: ${h.max / 1e6}ms`);
}, 5000);This gives you mean, max, and percentile lag over the sampling window without any external dependencies.
Patterns to Avoid Blocking
Break up CPU work with setImmediate
If you have a large array to process and cannot move it to a worker thread, you can yield to the event loop between chunks using setImmediate. It runs after the current poll phase, giving I/O and timers a chance to fire between batches.
function processInChunks(items, chunkSize, processFn, done) {
let index = 0;
function next() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
processFn(items[index]);
}
if (index < items.length) {
setImmediate(next); // yield, then continue
} else {
done();
}
}
next();
}Choose chunkSize so that each batch takes roughly 5β15ms. That keeps individual timer jitter low while still making progress efficiently.
Prefer async crypto and I/O APIs
Always reach for the async variant first. Most synchronous Node built-in methods have an async counterpart that runs off the main thread via libuv's thread pool.
const { pbkdf2 } = require('crypto');
// Good: runs in libuv thread pool
pbkdf2('password', 'salt', 100000, 64, 'sha512', (err, key) => {
if (err) throw err;
console.log(key.toString('hex'));
});
// Bad: blocks the main thread
// const key = pbkdf2Sync('password', 'salt', 100000, 64, 'sha512');Use Worker Threads for truly CPU-bound work
If you cannot chunk the work and it is genuinely CPU-intensive (image processing, large matrix operations, complex parsing), move it to a Worker. The main thread stays free, and the worker communicates results back via postMessage.
// main.js
const { Worker } = require('worker_threads');
const worker = new Worker('./heavy-task.js', {
workerData: { size: 1_000_000 }
});
worker.on('message', (result) => console.log('Result:', result));
worker.on('error', (err) => console.error(err));// heavy-task.js
const { workerData, parentPort } = require('worker_threads');
let sum = 0;
for (let i = 0; i < workerData.size; i++) sum += i;
parentPort.postMessage(sum);Workers do not share memory by default, so you avoid race conditions. You can share SharedArrayBuffer when you need high-throughput data exchange, but that adds complexity β start without it.
Common Pitfalls
Treating setTimeout as a scheduler. Some code uses setTimeout(fn, 0) to defer work, assuming it will run almost immediately. It will β but only if nothing else is blocking the loop. In a busy server, setTimeout(fn, 0) can still fire hundreds of milliseconds late under load.
Synchronous file reads in hot paths. fs.readFileSync is fine during startup. Using it inside a request handler blocks the loop for every request that hits that path, which compounds quickly under concurrency.
Deeply recursive microtasks. Wrapping synchronous recursion in Promise.resolve().then() does not make it non-blocking. Microtasks drain their entire queue before any I/O or timers run, so recursive microtask scheduling has the same starvation effect as synchronous recursion.
Not accounting for timer resolution. Node's timer resolution is not sub-millisecond on most operating systems. Very short delays (under 1ms) are often rounded up to the minimum system timer interval. Design your code to be tolerant of a few milliseconds of jitter in timer callbacks.
Wrapping Up
The event loop is the engine behind everything async in Node.js, and its rules are simple: one thing at a time, phases in order, timers only fire when the loop gets there. When your setTimeout fires late, the loop was busy elsewhere β and now you know exactly where to look.
Concrete next steps:
- Add the
monitorEventLoopDelayheartbeat to your staging environment and set an alert threshold (50ms is a reasonable starting point for most web services). - Audit your request handlers for synchronous
*SyncAPI calls βfs.readFileSync,pbkdf2Sync,execSyncβ and replace them with their async counterparts. - Profile a CPU-spike in your app using
node --profandnode --prof-processto find which functions dominate the flame graph. - If you have a data-processing pipeline that iterates over large arrays, prototype the chunk-and-
setImmediatepattern and measure the effect on timer jitter. - For genuinely heavy computation, explore
worker_threadsβ start with one worker and a simple message-passing interface before adding pooling complexity.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!