Backend in Depth
Node.js Performance Basics: Event Loop Blocking, Streams, Workers
Why one heavy CPU job can stop every request, plus streams for large data and worker threads.
On this page
- Words used in this lesson
- The event loop, server edition
- What blocks the event loop
- Scenario: p99 latency spikes whenever one report endpoint is hit
- Streams: handle big data without loading it
- Concurrency: cluster, worker threads, child processes, containers
- Other practical Node performance habits
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: Node runs your JavaScript on one thread. If something slow or CPU-heavy runs on that thread, every request must wait until it finishes. Keep the event loop free, stream big data, and move heavy work somewhere else.
Browser developers already know the main-thread rule from rendering (Browser Contract, Lesson 08). On the server, the same rule has a different result. In the browser you get a choppy animation. On the server, one slow request makes other users time out.
Words used in this lesson
- Node.js is a program that runs JavaScript outside the browser, mostly for servers.
- Thread is one line of execution inside a program. It does one step at a time. Process is a running program with its own memory. A process has at least one thread. Difference in one line: threads inside a process can share memory, but separate processes cannot.
- Event loop is the part of Node that picks the next piece of work (a callback) and runs it. It runs on the single JavaScript thread.
- Blocking means the thread is busy and cannot do anything else. Non-blocking means the thread hands the job off and keeps working.
- I/O-bound work spends most of its time waiting for disks, networks or databases. CPU-bound work spends its time calculating, so the processor is the limit.
- Synchronous code runs and finishes before the next line starts. Asynchronous code starts a job and lets other code run until the job is done.
- Latency is how long one request takes. p99 latency is the time that 99 out of 100 requests beat. It shows the slow cases.
- Stream is data that you handle piece by piece (chunks) instead of all at once.
The event loop, server edition
The event loop is the part of Node that picks the next piece of work to run. Think of one waiter in a restaurant. The waiter takes an order, gives it to the kitchen, and goes to the next table while the food cooks. The waiter is fast because they never stand and wait.
Node can handle thousands of connections with one JavaScript thread because I/O does not block it. When you write await db.query(), Node gives the job to the operating system (or a worker pool) and moves on to other requests. The loop runs in phases: timers, pending callbacks, idle and prepare (used by Node itself), poll for I/O, check (where setImmediate callbacks run), and close callbacks. A callback is a function that Node runs later, when a job is done. The loop runs one callback at a time until that callback is finished.
Request A: parse → await DB ──(waiting, loop is free)──▶ continue → respondRequest B: parse → await cache ─(waiting)─▶ continue → respondThis model is great for I/O-bound work. That means APIs that mostly wait for databases and networks. It is bad for CPU-bound work, where the CPU does the heavy lifting.
Node's hidden thread pool
Not everything runs on the loop. libuv (the C library under Node that does I/O) keeps a small thread pool. A thread pool is a fixed group of worker threads that take jobs from a queue. The default size is 4, and you can change it with UV_THREADPOOL_SIZE (set it before the process starts). The pool is used for:
- file system operations
dns.lookup(turns a host name into an IP address using the operating system)- some
cryptofunctions (such aspbkdf2andscrypt, which hash passwords on purpose slowly, andrandomBytes) zlibcompression (making data smaller, for example gzip)
Network I/O uses the operating system's non-blocking sockets instead. So if many password hashes or compression jobs arrive at once, they wait in a queue for four threads. This can slow down file or DNS operations that have nothing to do with them.
What blocks the event loop
| Cause | Example | Why it hurts |
|---|---|---|
| Synchronous APIs | fs.readFileSync, execSync, crypto.pbkdf2Sync, bcrypt.hashSync in a request path | The loop waits and does nothing else |
| Big JSON | JSON.parse/JSON.stringify of a 50 MB body or response | It is synchronous and CPU-bound. It gets slower as the data grows |
| CPU-heavy loops | Making a report, resizing images, sorting millions of rows, rendering Markdown or PDF inside the process | Pure calculation on the main thread |
| Catastrophic regex (ReDoS) | /(a+)+$/ against "aaaaaaaaaaaaaaaaaaaa!" | A regex (regular expression) is a text-matching pattern. This one makes the engine try too many ways to match (exponential backtracking: the work doubles with each extra character). A small input can freeze the process |
| Huge synchronous array work | .map().filter().reduce() over millions of items | Long tasks that cannot be interrupted |
| Deep recursion or copying large objects | structuredClone of giant structures | Blocks until it is done |
Unbounded Promise.all (Promise.all waits for many promises at once; unbounded means no limit on how many) | Starting 50,000 operations at the same time | Floods the pool, the memory and the loop with callbacks |
// ❌ One request to /report blocks every other request for secondsapp.get("/report", (req, res) => { const rows = loadAllRowsSync(); // sync I/O const result = heavyAggregate(rows); // pure CPU res.json(result);});Seeing it: event-loop lag
Event-loop lag is the delay between when the loop should run something and when it really does. To measure it, check how late timers fire. If the loop is blocked for 400 ms, a 10 ms timer fires about 400 ms late.
import { monitorEventLoopDelay } from "node:perf_hooks";const h = monitorEventLoopDelay({ resolution: 10 });h.enable();setInterval(() => { logger.info({ p50: h.percentile(50) / 1e6, p99: h.percentile(99) / 1e6, max: h.max / 1e6 }, "event loop delay (ms)"); h.reset();}, 10_000).unref();// The timer fires every 10 ms (the resolution), so even an idle process reads about 10 ms.// Look at how far the numbers rise above that.Send this number out as a metric and set an alert for a high p99 lag (Lesson 05). A metric is a number your app reports over time. (p99 means 99% of the measurements are faster than this value. p50 is the median.) The numbers include the 10 ms timer interval, so a healthy service shows about 10 to 15 ms. A value far above that, such as several hundred milliseconds, means the loop was blocked. For deeper checks, use a CPU profile (a record of which functions used the processor and for how long). Node can write one with --cpu-prof, or you can connect Chrome DevTools with node --inspect. Tools like Clinic.js and 0x draw flame graphs, which are charts that show where the time goes.
Scenario: p99 latency spikes whenever one report endpoint is hit
Symptoms: Most endpoints take 20 ms. Every few minutes, everything jumps to several seconds. This happens at the same time as calls to /reports/monthly.
Diagnosis: The report handler does heavy CPU work (or turns a giant object into JSON) on the main thread. While it runs, the event loop cannot serve any other request. You can confirm this with event-loop-lag metrics and a CPU profile.
Fixes, from best to last choice:
- Move the work into the database. Use aggregates,
GROUP BYand window functions instead of pulling rows into JavaScript. - Make it asynchronous. Accept the request, put a job in a queue (a list of jobs waiting to be done, for example in Redis, or in SQS, which is Amazon's queue service) and return
202(the HTTP status "Accepted": I got it, I will do it later). A separate worker process makes the report and saves it (for example in S3, Amazon's object storage, with a status endpoint). Users check the status or get a message when it is ready (Practical Design Problems, Lesson 07). - Move CPU work to
worker_threads. This is a Node module that runs JavaScript on extra threads, each with its own event loop. A library likepiscinamanages a pool of them. The main loop stays free. - Stream large results instead of building the whole result in memory.
- Split the work into chunks. Process 1,000 rows, then give control back with
setImmediate(it runs a function on the next loop turn, so other requests get a chance). This is a short-term fix. - Isolate it. Serve heavy endpoints from a separate deployment, so they cannot starve the main API.
- Cache (keep a saved copy of) the result if many users get the same report.
Streams: handle big data without loading it
Reading a 2 GB file into memory with fs.readFile uses 2 GB of memory. fs.readFile also refuses files larger than 2 GiB. So one request can use up all the memory. Streams process data in small pieces (chunks). Think of a water pipe instead of a bucket: water flows through and you never hold all of it:
import { createReadStream } from "node:fs";import { pipeline } from "node:stream/promises";import { createGzip } from "node:zlib";app.get("/exports/:id", async (req, res) => { res.setHeader("Content-Type", "text/csv"); res.setHeader("Content-Encoding", "gzip"); await pipeline(createReadStream(pathFor(req.params.id)), createGzip(), res); // constant memory, handles backpressure});Key ideas:
- Backpressure means "slow down, I am not ready". The producer is the side that makes data. The consumer is the side that receives it (for example a slow client). If the consumer cannot keep up, the stream pauses the producer. It does not store everything in memory. Use
stream.pipeline(orpipelinefromnode:stream/promises). It also handles errors and cleans up when something fails. A plain.pipe()does not do this. - Stream database cursors, S3 objects, CSV or JSON-lines exports, and uploads (send the request straight into storage). Even better: let clients upload and download directly to object storage (a service like S3 that stores files) with presigned URLs (temporary signed links that allow one upload or download). Then your API never touches the bytes (Practical Design Problems, Lesson 04).
- For JSON, prefer NDJSON (newline-delimited JSON: one JSON object per line) for large exports. The reader can then parse it piece by piece.
Concurrency: cluster, worker threads, child processes, containers
| Option | What it is | Use for |
|---|---|---|
cluster (or PM2 cluster mode; PM2 is a process manager for Node) | Several Node processes that share one port, one for each CPU core | Using all cores for an I/O-bound API on one machine |
worker_threads | Threads inside one process, each with its own event loop (they can share memory with SharedArrayBuffer, a block of memory both threads can read and write) | CPU-bound tasks (hashing, parsing, image processing) without blocking the main loop |
child_process | Starts a separate program (a CLI tool, ffmpeg, Python) | Running other programs, with strong separation |
| Horizontal scaling (containers, many instances behind a load balancer, which is a server that spreads requests across instances) | More copies of the whole app | The usual cloud answer: add instances and keep each process simple |
In container setups, most teams run one Node process in each container (a container is a packaged app with its own isolated environment) and change the number of containers. cluster matters mostly on a plain VM (virtual machine).
Other practical Node performance habits
- Never use sync APIs on request paths. They are fine at startup.
- Use a fast JSON serialiser (a tool that turns objects into JSON text) or schema-based serialisation (Fastify's
fast-json-stringify) for busy endpoints. Avoid huge responses. Paginate, and compress (gzipor Brotli, which is often better done at the proxy or CDN). - Reuse connections. Use HTTP keep-alive (keeping a connection open for the next request) agents for outgoing calls, and a shared database pool.
- Limit concurrency (how many jobs run at the same time) when you send many requests at once (
p-limitis a small library for this). Do not fire thousands at once. - Set timeouts everywhere and limit request body sizes.
- Watch memory. Heap usage that keeps growing means a leak. Common causes are event listeners, caches with no limit, and closures (functions that keep old variables alive). The heap is the memory where objects live. Take heap snapshots (a saved picture of all objects in memory). Set
--max-old-space-size(the heap size limit, in MB) to fit the container. - Cache expensive work that repeats. Use an in-process LRU cache (it drops the least recently used items) for small hot data, and Redis for shared data.
- Use a current Node version and keep dependencies up to date. Engine improvements are free speedups.
- Test with realistic load (
autocannonand k6 are tools that send many requests) and look at p95 and p99, not only throughput (requests per second).
Common mistakes
- CPU-heavy work inside request handlers.
*Synccalls in busy code paths.- Loading whole files or result sets into memory instead of streaming.
- Unbounded
Promise.allover big lists. - Using
.pipe()without error handling. Streams and file descriptors leak. - Not measuring event-loop lag. Blocking stays invisible until an incident.
- Regexes on untrusted input without checking for catastrophic backtracking.
- Believing "async/await" means "runs in parallel". It only avoids blocking while waiting, not while calculating.
Interview questions
What blocks the Node event loop?
Anything that runs for a long time without stopping on the main thread. Examples are sync I/O (readFileSync), CPU-heavy loops, large JSON.parse or stringify, catastrophic regexes, big sorts or aggregations in memory, and synchronous crypto. While it runs, no other request is processed. Waiting for I/O with await does not block the loop.
Scenario: p99 latency spikes whenever one report endpoint is hit. Why?
That endpoint is probably doing CPU-bound or synchronous work on the main thread. This starves every other request. I would confirm it with event-loop-lag metrics and a CPU profile. Then I would move the aggregation into the database, or send it to a queue and a worker (returning 202). Other options are worker_threads, streaming the output, or putting the endpoint in its own service.
Streams vs buffering a 2 GB file?
Buffering loads all 2 GB into memory. This can use up the memory, and the client waits a long time for the first byte. Streaming reads and writes small chunks, so memory stays the same and backpressure is handled. The client starts receiving data right away. I use stream.pipeline so errors are handled correctly. For large transfers, I can hand the job to object storage with presigned URLs.
Cluster vs workerthreads?
cluster starts several processes so that many CPU cores can handle requests at the same time. Each process has its own memory and event loop. worker_threads runs extra threads inside one process. They are good for moving CPU-bound tasks away, while the main thread keeps serving requests. In containers, I usually add more container instances instead of using cluster.
What is libuv's thread pool and why does it matter?
It is a default pool of four threads. It handles file system operations, dns.lookup, some crypto (pbkdf2, scrypt) and zlib. If I use these a lot, the pool can become full and delay other unrelated operations. I can raise UV_THREADPOOL_SIZE, or move the heavy work to workers or other services.
How do you detect event-loop blocking in production?
I record the event-loop delay with perf_hooks.monitorEventLoopDelay. I send p50, p99 and max as metrics and set alerts. I compare them with the latency spikes. Then I use CPU profiles or flame graphs (Clinic.js, --cpu-prof) to find the cause.
What is backpressure?
It is a way for a slow consumer to tell the producer to pause. This way data is not stored without limit. Node streams do this. pipeline connects everything and makes sure cleanup happens. Without backpressure, a fast source and a slow client can use up all the memory.
Does async/await make code parallel?
No. It lets the single thread do other work while it waits for I/O. But JavaScript callbacks still run one at a time. CPU-bound work inside an async function still blocks the loop. For parallel CPU work, I need worker threads, processes or separate services.
Scenario: a 4-core server runs one Node process at 100% on one core while the others idle. What do you do?
Node runs JavaScript on one thread, so one process uses one core. For I/O-bound traffic, I run several processes (cluster, PM2, or several containers behind a load balancer). For CPU-heavy tasks, I use worker threads or a job queue. I also check the hot path with a CPU profile. Removing synchronous work or reducing JSON size may help more than adding cores.
Key takeaways
- One JS thread: keep request handlers short and async. Keep CPU-heavy and synchronous work out of them.
- Measure event-loop lag and set an alert. Use flame graphs to find the cause.
- Stream large data with
pipelineand backpressure, or skip the server with presigned URLs. - Move heavy calculation to the database, a queue plus a worker, or
worker_threads. Scale out with more instances. - The libuv thread pool (default 4) serves fs, DNS lookup, some crypto and zlib.
What's next
Lesson 11: Testing an API. We finish the series with how to prove that all of this works, and keeps working: unit, integration and contract tests.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Pagination, Filtering and Sorting · API Design
- HTTP Caching: Cache-Control, ETags and the CDN in Between · The Browser Contract
- Browser Rendering and the Event Loop in Practice · The Browser Contract
Related series