Concurrency in Node.js: The Event Loop, Worker Threads, and Beyond
Node.js is often described in a single, slightly misleading sentence: "it is single-threaded." That statement is true of the JavaScript code you write, but it hides a much richer reality. A running Node.js process coordinates a single JavaScript thread, a pool of background threads managed by libuv, the operating system's asynchronous I/O facilities, and optionally several additional JavaScript threads spawned as workers. Understanding how these pieces fit together is what separates code that scales gracefully from code that mysteriously stalls under load. This article works through the whole picture: the event loop, the microtask and macrotask queues, the libuv thread pool, patterns for coordinating asynchronous work, true parallelism with worker threads and shared memory, multi-process scaling, and the pitfalls that block everything.
Concurrency Versus Parallelism
These two terms are frequently conflated, but they describe different properties of a program. Concurrency is a structural property: a program is concurrent when it is composed of multiple tasks that can make progress independently, interleaving over time. Parallelism is an execution property: a program runs in parallel when multiple tasks execute at the very same instant on different CPU cores.
A single CPU core can run many concurrent tasks by switching between them rapidly, giving the illusion of simultaneity without ever running two of them at the same physical moment. Parallelism, by contrast, requires multiple cores and a runtime willing to use them. Node.js is built to be extremely good at concurrency on a single thread, and it can additionally reach for parallelism when the workload demands it.
The decision of which tool to use hinges almost entirely on whether a task is I/O-bound or CPU-bound. An I/O-bound task spends most of its time waiting for something external: a network response, a disk read, a database query. A CPU-bound task spends most of its time computing: hashing, compressing, resizing images, or crunching numbers in a tight loop. Node.js handles I/O-bound work exceptionally well with its single-threaded event loop. CPU-bound work is where the single thread becomes a liability, and where worker threads or separate processes become necessary.
The Single-Threaded Illusion
When you run a Node.js program, exactly one thread executes your JavaScript. There is no preemption inside that thread: a function runs to completion before any other JavaScript can run. This is a deliberate and powerful design choice, because it eliminates an entire category of concurrency bugs. Two pieces of JavaScript never touch the same variable at the same time, so you never need mutexes or locks to protect ordinary objects.
The magic is that Node.js almost never actually waits. When your code asks to read a file or send a network request, it hands the operation off to the system and immediately continues. The result arrives later as a callback scheduled on the event loop. This is why a single thread can serve thousands of simultaneous connections: it is never blocked waiting on any one of them, only orchestrating the ones that are ready.
The engine that makes this orchestration possible is the event loop, provided by the C library libuv.
The Event Loop
The event loop is a continuous cycle that checks for completed operations and runs the callbacks associated with them. Each turn of the loop is divided into a fixed sequence of phases, and each phase has its own queue of callbacks to process. The phases, in order, are:
Timers. This phase executes callbacks scheduled by setTimeout() and setInterval() whose threshold has elapsed. A timer set for 0 milliseconds does not fire immediately; it fires on the next timers phase once the loop reaches it.
Pending callbacks. This phase runs certain system-level callbacks deferred from a previous loop iteration, such as some TCP error callbacks reported by the operating system.
Idle and prepare. These are internal phases used by libuv itself and are not directly observable from JavaScript.
Poll. This is the heart of the loop. It retrieves new I/O events and executes their callbacks: incoming connections, data ready to read, writes that have completed. If there are no timers pending, the loop can block here waiting for I/O, which is exactly the behavior you want when the process is idle.
Check. This phase runs callbacks scheduled with setImmediate(). It exists specifically so you can schedule work to run right after the poll phase completes.
Close callbacks. This phase handles cleanup callbacks such as a socket's 'close' event.
The following example shows how these phases interact. The relative order of a zero-delay timer and an immediate callback is non-deterministic when scheduled from the main module, because it depends on how much time the process spends starting up before entering the loop:
console.log('script start');
setTimeout(() => {
console.log('timeout fired');
}, 0);
setImmediate(() => {
console.log('immediate fired');
});
console.log('script end');
// Possible output:
// script start
// script end
// timeout fired <- order of these two
// immediate fired <- is not guaranteed here
The ordering becomes deterministic, however, when both are scheduled from inside an I/O callback. In that context the loop is already past the timers phase and about to hit the check phase, so setImmediate() always runs before the next setTimeout():
import { readFile } from 'node:fs';
readFile('package.json', () => {
// We are now inside the poll phase's I/O callback.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Deterministic output:
// immediate
// timeout
});
Microtasks and Macrotasks
The phase callbacks described above are sometimes called macrotasks. Between them sits a second, higher-priority mechanism: the microtask queues. Node.js maintains two of these, and it drains them completely after every single macrotask, not just at phase boundaries.
The first microtask queue belongs to process.nextTick(). Callbacks queued here run before anything else, even before resolved promises. The second is the promise microtask queue, which holds the continuations of resolved and rejected promises, including everything scheduled by await. The nextTick queue always drains fully before the promise queue is touched.
This ordering explains the output of a classic example that surprises many developers:
console.log('1: synchronous start');
setTimeout(() => console.log('2: timeout (macrotask)'), 0);
Promise.resolve().then(() => console.log('3: promise (microtask)'));
process.nextTick(() => console.log('4: nextTick (highest priority)'));
console.log('5: synchronous end');
// Output:
// 1: synchronous start
// 5: synchronous end
// 4: nextTick (highest priority)
// 3: promise (microtask)
// 2: timeout (macrotask)
The synchronous code runs first and to completion. Then, before the loop advances to the timers phase, both microtask queues drain in priority order: nextTick first, then the resolved promise. Only after both are empty does the macrotask (the timer) get its turn.
A practical consequence is that a recursive process.nextTick() can starve the event loop entirely, because the loop refuses to advance until the nextTick queue is empty. Recursive promise chains have the same risk. When you need to yield back to the loop so I/O can be serviced, prefer setImmediate().
The libuv Thread Pool
Not every asynchronous operation is handled by the operating system's non-blocking I/O. Some operations have no non-blocking equivalent, or are genuinely computational. For these, libuv maintains a small pool of background threads. When you call one of these functions, the actual work runs on a pool thread, and only the completion callback is scheduled back onto the event loop.
The operations that use the thread pool include most of the fs module (file system access), crypto functions such as pbkdf2 and randomBytes, zlib compression, and DNS lookups performed with dns.lookup(). Network sockets, by contrast, use the operating system's native asynchronous facilities and do not touch the pool.
The pool defaults to four threads. This default is invisible until you saturate it: issue five slow crypto.pbkdf2() calls at once and the fifth will wait for one of the first four to finish, even though the CPU may have far more cores available. The size is controlled by the UV_THREADPOOL_SIZE environment variable, which must be set before the process starts because the pool is created lazily on first use:
# Raise the pool to 8 threads before launching the app
UV_THREADPOOL_SIZE=8 node server.js
The following program demonstrates the saturation effect. With the default pool size, the timings cluster into two groups because only four hashes can run at once:
import { pbkdf2 } from 'node:crypto';
const start = Date.now();
// Fire six expensive key derivations at the same time.
for (let i = 0; i < 6; i++) {
pbkdf2('secret', 'salt', 100_000, 64, 'sha512', () => {
console.log(`hash ${i} done after ${Date.now() - start} ms`);
});
}
// With UV_THREADPOOL_SIZE=4, hashes 0-3 finish together,
// and hashes 4-5 finish in a second, later batch.
Callbacks, Promises, and async/await
Asynchronous code in Node.js has evolved through three styles. The oldest is the error-first callback, where the last argument is a function invoked with an error (or null) followed by the result. This style works but composes poorly, producing deeply nested "callback pyramids" when several operations depend on one another.
Promises replaced nesting with chaining. A promise is an object representing a value that will exist eventually, and its .then() and .catch() methods let you sequence operations linearly. The async and await keywords are syntactic sugar over promises: an async function always returns a promise, and await suspends the function until a promise settles, without blocking the event loop. Under the hood, everything after an await is scheduled as a promise microtask.
The node:util module provides promisify() to bridge legacy callback APIs into the promise world, and most core modules now expose promise-based variants directly, such as node:fs/promises:
import { readFile, writeFile } from 'node:fs/promises';
async function copyUppercased(source, destination) {
// Each await suspends this function without blocking the loop.
const contents = await readFile(source, 'utf8');
const transformed = contents.toUpperCase();
await writeFile(destination, transformed);
return transformed.length;
}
try {
const written = await copyUppercased('in.txt', 'out.txt');
console.log(`Wrote ${written} characters`);
} catch (error) {
// A single catch handles rejections from either await.
console.error('Copy failed:', error.message);
}
It is important to remember that await serializes work. The two reads below happen one after the other even though they are independent, wasting time:
// Sequential: total time is the sum of both reads.
const a = await readFile('a.txt', 'utf8');
const b = await readFile('b.txt', 'utf8');
Coordinating Concurrent Asynchronous Work
When independent asynchronous operations do not depend on one another, they should be started together and awaited as a group. The Promise constructor exposes four combinators for this, each with different semantics.
Promise.all() waits for every promise to fulfil and returns their results in order. If any single promise rejects, the combined promise rejects immediately with that error, and the other results are discarded. This is the right choice when you need every result and any failure should abort the whole operation:
// Concurrent: both reads run at once, total time is the slower one.
const [a, b] = await Promise.all([
readFile('a.txt', 'utf8'),
readFile('b.txt', 'utf8'),
]);
Promise.allSettled() also waits for every promise, but it never rejects. It resolves to an array of objects, each describing whether its promise was fulfilled or rejected. Use it when partial success is acceptable and you want to inspect every outcome:
const results = await Promise.allSettled([
fetch('https://api.one.example/data'),
fetch('https://api.two.example/data'),
]);
for (const result of results) {
if (result.status === 'fulfilled') {
console.log('OK:', result.value.status);
} else {
console.warn('Failed:', result.reason.message);
}
}
Promise.race() settles as soon as the first promise settles, whether it fulfils or rejects. It is commonly used to attach a timeout to an operation. Promise.any() is similar but ignores rejections: it fulfils with the first success and only rejects if every promise rejects, which suits redundant requests to mirrored endpoints.
Limiting Concurrency
Firing hundreds of requests at once with Promise.all() can overwhelm a downstream service or exhaust file descriptors. The solution is a concurrency limit: a pool that keeps at most N operations in flight, starting the next only as one finishes. The pattern below implements this without any dependency:
async function mapWithLimit(items, limit, task) {
const results = new Array(items.length);
let nextIndex = 0;
// Each worker pulls the next available index until none remain.
async function worker() {
while (nextIndex < items.length) {
const current = nextIndex++;
results[current] = await task(items[current], current);
}
}
// Launch exactly `limit` workers and wait for all of them.
const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
await Promise.all(workers);
return results;
}
const urls = ['/a', '/b', '/c', '/d', '/e', '/f'];
const bodies = await mapWithLimit(urls, 2, async (url) => {
const response = await fetch(`https://api.example${url}`);
return response.json();
});
Cancellation with AbortController
Long-running asynchronous work sometimes needs to be cancelled: a request that is no longer relevant, an operation that has exceeded its budget. Node.js adopts the web standard AbortController for this. A controller produces a signal that can be passed to any cancellation-aware API; calling abort() causes the pending operation to reject with an AbortError.
const controller = new AbortController();
// Cancel automatically after 2 seconds.
const timer = setTimeout(() => controller.abort(), 2_000);
try {
const response = await fetch('https://slow.example/report', {
signal: controller.signal,
});
const data = await response.json();
console.log('Received', data);
} catch (error) {
if (error.name === 'AbortError') {
console.warn('Request cancelled after timeout');
} else {
throw error;
}
} finally {
clearTimeout(timer);
}
Worker Threads: True Parallelism
Everything discussed so far is concurrency on a single thread. It is ideal for I/O-bound work, but it collapses under CPU-bound work: a synchronous loop that runs for two seconds blocks the entire event loop for two seconds, freezing every connection. The solution is the worker_threads module, which lets you run JavaScript on additional operating-system threads, each with its own event loop and V8 instance.
A worker is created by pointing at a script file. The main thread and the worker communicate by passing messages, which are copied using the structured clone algorithm. The main thread stays responsive because the heavy computation happens elsewhere:
// main.js
import { Worker } from 'node:worker_threads';
function computeInWorker(payload) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', { workerData: payload });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
});
});
}
// The main thread is free while the worker sums a large range.
const total = await computeInWorker({ start: 1, end: 1_000_000_000 });
console.log('Sum:', total);
// worker.js
import { parentPort, workerData } from 'node:worker_threads';
let sum = 0;
for (let i = workerData.start; i <= workerData.end; i++) {
sum += i;
}
// Send the result back to the main thread and let the worker exit.
parentPort.postMessage(sum);
Because creating a worker has a startup cost, production systems usually maintain a pool of reusable workers rather than spawning one per task. Libraries such as piscina implement this pattern, and the built-in AsyncResource API helps integrate workers cleanly with async context tracking.
Sharing Memory: SharedArrayBuffer and Atomics
Passing messages copies data, which is fine for small payloads but wasteful for large buffers. When threads must share a large region of memory without copying, Node.js offers SharedArrayBuffer. Unlike an ordinary ArrayBuffer, a shared buffer is not cloned when posted; both threads reference the same underlying bytes.
Sharing memory reintroduces the race conditions that single-threaded Node.js normally avoids, so the Atomics object provides operations that read, modify, and write a shared value indivisibly, along with primitives to make threads wait and wake one another. The example below has a worker increment a shared counter safely:
// main.js
import { Worker } from 'node:worker_threads';
// A shared buffer holding a single 32-bit integer.
const shared = new SharedArrayBuffer(4);
const counter = new Int32Array(shared);
const worker = new Worker('./increment.js', { workerData: shared });
worker.on('exit', () => {
// Atomics.load reads the final value with proper memory ordering.
console.log('Final counter:', Atomics.load(counter, 0));
});
// increment.js
import { workerData } from 'node:worker_threads';
const counter = new Int32Array(workerData);
for (let i = 0; i < 1_000_000; i++) {
// Atomics.add is indivisible: no increment is ever lost.
Atomics.add(counter, 0, 1);
}
Scaling Across Processes: The Cluster Module
Worker threads share a single process and its memory. Sometimes you instead want full isolation and the ability to use every CPU core for handling network traffic. The cluster module forks the process into several workers that share the same listening socket, with the operating system distributing incoming connections among them. Each worker is a complete, independent Node.js process.
import cluster from 'node:cluster';
import { createServer } from 'node:http';
import { availableParallelism } from 'node:os';
if (cluster.isPrimary) {
// The primary forks one worker per logical CPU.
const cpuCount = availableParallelism();
for (let i = 0; i < cpuCount; i++) {
cluster.fork();
}
// Restart any worker that dies, keeping the pool full.
cluster.on('exit', (worker) => {
console.warn(`Worker ${worker.process.pid} died; restarting`);
cluster.fork();
});
} else {
// Every worker shares the same port via the primary.
createServer((request, response) => {
response.end(`Handled by process ${process.pid}\n`);
}).listen(3000);
}
In practice, many deployments delegate this responsibility to an external process manager such as PM2 or to a container orchestrator, which provides the same multi-process scaling along with monitoring and zero-downtime restarts.
Streams and Backpressure
Streams are Node.js's model for processing data incrementally rather than loading it entirely into memory. They are inherently concurrent: a readable stream can be producing data while a writable stream consumes it. The critical concept is backpressure, the mechanism by which a fast producer is slowed to match a slow consumer.
Wiring streams together by hand and respecting backpressure correctly is error-prone, because you must watch 'drain' events and propagate errors from every stage. The pipeline() helper from node:stream/promises handles all of this, including cleanup on failure:
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
// Read, compress, and write a file with automatic backpressure.
// The read pauses whenever the write cannot keep up.
await pipeline(
createReadStream('large.log'),
createGzip(),
createWriteStream('large.log.gz'),
);
console.log('Compression complete');
Blocking the Event Loop
The single greatest performance hazard in Node.js is blocking the event loop. Because one thread runs all your JavaScript, any synchronous operation that takes a long time stops every other request, timer, and callback until it finishes. A server that feels frozen under load is very often blocked, not overloaded.
The usual culprits are synchronous file system calls in a request handler, such as readFileSync(); expensive synchronous computation like large JSON parsing or complex regular expressions; and unbounded loops over large data structures. Regular expressions deserve special caution: certain patterns exhibit catastrophic backtracking, taking exponential time on a crafted input and hanging the process, a vulnerability known as ReDoS.
The remedies follow directly from the earlier sections. Move genuine computation to a worker thread. Replace synchronous I/O with its asynchronous equivalent. Break a large loop into chunks that yield to the loop between batches, using setImmediate() so I/O can be serviced in between:
async function processInChunks(items, chunkSize, handle) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
for (const item of chunk) {
handle(item);
}
// Yield to the event loop so pending I/O can run.
await new Promise((resolve) => setImmediate(resolve));
}
}
Handling Unhandled Rejections
A promise that rejects with no attached rejection handler produces an unhandled rejection. In modern Node.js this terminates the process by default, on the reasoning that an application in an unknown error state should not keep running. You should attach .catch() to every promise chain, or wrap every await in a suitable try/catch, rather than relying on a global handler as anything more than a last-resort logger:
process.on('unhandledRejection', (reason) => {
// Log for diagnostics, then exit; do not silently swallow.
console.error('Unhandled rejection:', reason);
process.exit(1);
});
Choosing the Right Model
The Node.js concurrency toolkit maps cleanly onto the nature of the work. For I/O-bound tasks, which describe the majority of typical server workloads, the single-threaded event loop with promises and async/await is the natural and most efficient choice; coordinate independent operations with the Promise combinators and cap them with a concurrency limiter. For CPU-bound tasks that would otherwise block the loop, reach for worker_threads, sharing large data through SharedArrayBuffer and Atomics when copying is too costly. To scale network handling across every core, fork the process with the cluster module or a process manager. And whatever the model, guard the event loop jealously: keep synchronous work short, stream large data instead of buffering it, and always handle rejections. Mastering these tools turns Node.js from a platform that merely tolerates concurrency into one that embraces it.