Skip to content

Node Backend Frameworks

core

Assumes you have read: The Event Loop, Concurrency and Parallelism

The event loop page establishes the model: one thread runs your JavaScript, run-to-completion, and I/O is handed off rather than waited on. That model does not change when you add Express or Fastify. A framework is not a new concurrency primitive — it is a router, a middleware pipeline, and a serialization layer, all still running on the one thread the event loop gives you.

Which means the question a framework’s documentation never quite answers is the one that matters in production: what does adding this framework cost on top of the model you already have? The answer is a constant factor per request — routing lookup, middleware dispatch, JSON serialization — not a different saturation point. The thread still saturates the same way it did on a bare http.createServer: one handler that doesn’t yield blocks everyone behind it, framework or no framework.

The one thing frameworks change for real is how easy that is to trigger by accident. A middleware chain hides where synchronous work lives. Error handling that swallows a rejected promise turns a crash into a hang. Neither of these is a new failure mode — both are the event loop’s existing rule, reached through one extra layer of indirection.

The middleware chain is just nested function calls

Section titled “The middleware chain is just nested function calls”

Express middleware is (req, res, next) => void. Each middleware must do one of two things: send a response (res.send, res.json, res.end) to end the request-response cycle, or call next() to pass control to the next handler. Doing neither — a code path that returns without either — stalls the request forever, because nothing else will resume it.

app.use((req, res, next) => {
req.startedAt = Date.now();
next(); // required — the request simply hangs without it
});
app.use(async (req, res, next) => {
try {
req.user = await authenticate(req);
next();
} catch (err) {
next(err); // routes to error-handling middleware
}
});

Two consequences fall out of “just nested function calls”:

Express 4 does not catch a rejected promise for you. An async middleware that throws produces an unhandled rejection unless you call next(err) yourself, or wrap it. Express 5 (2024) fixed this — a rejected handler is routed to error middleware automatically — but a lot of production code still runs on Express 4’s semantics, so the manual try/catch above is not defensive boilerplate, it is load-bearing.

Middleware order is request-scoped serial execution, not parallelism. Twelve middleware functions run one after another, each blocking the next until it calls next() or awaits something. Twelve middleware doing 1 ms of synchronous work each is 12 ms added to every request, always — the same event-loop-occupancy accounting the event loop page derives, just spread across more functions instead of one handler.

Fastify: the same model, a different tradeoff

Section titled “Fastify: the same model, a different tradeoff”

Fastify’s pitch is not a different concurrency model — it is faster serialization (a JSON schema compiled to a specialized stringifier) and a plugin/hook system in place of Express’s middleware chain. The request-per-tick, one-handler-per-turn reality underneath is identical.

import express from 'express';
const app = express();
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user); // JSON.stringify — untyped, walks the object
});
app.listen(3000);

The schema buys a genuinely faster serialize path, but it is still synchronous work on the same thread — a large response with a compiled serializer is cheaper per byte than JSON.stringify, not free. It moves the constant, it does not remove the mechanism.

Run the numbers rather than assert them. Two routes on a bare Express app: /fast, which does nothing but await a microtask before responding, and /block, which spends 20 ms in a synchronous loop before responding — the same shape as an unoptimized regex, a synchronous crypto call, or JSON.parse on a large body.

Captured on this machine, Node v24.12.0, Express 5.1.0, 2026-08-18. Load generator: scripts/bench/load.mjs in this repository, which fires concurrency workers that each loop fetchawait res.json(), timing wall-clock per request against the server fixtures in scripts/bench/node-backends/ — see scripts/bench/README.md for the exact commands. Concurrency 50 throughout.

GET /fast (no blocking work), 500 requests, concurrency 50
wallMs: 74 throughput: 6757 req/s p50: 4ms p99: 18ms
GET /block (20ms synchronous loop), 200 requests, concurrency 50
wallMs: 4017 throughput: 50 req/s p50: 960ms p99: 1940ms max: 2917ms

200 requests × 20 ms of pure serial work is exactly 4,000 ms if every request queues behind the last — and the measured wall time is 4,017 ms. That is not a coincidence: with one thread, 50 concurrent connections to a handler that blocks for 20 ms do not run in parallel. They all queue behind whichever one currently holds the thread, and the queue is exactly the M/D/1M/D/1 model the event loop page derives. p99 latency (1,940 ms) is roughly a hundred times the handler’s own 20 ms — that gap is the queueing delay, and it is not framework-specific; Fastify measured the same throughput on the same non-blocking route (see below) because the route did no synchronous work, and would show the identical collapse if it did.

Framework overhead, isolated. The same non-blocking /fast route on Fastify 5, same machine, same load generator: 500 requests, concurrency 50 → wall 66 ms, 7,576 req/s, p50 4 ms, p99 17 ms — statistically indistinguishable from Express’s 6,757 req/s at this request size and rate. At the scale where routing and serialization become measurable — thousands of routes, large response payloads, high sustained RPS — Fastify’s compiled serializer and lower-overhead router show a real gap; on a single trivial JSON route neither framework’s constant factor dominates. The resource that saturates first is the thread, and no framework choice changes that — it only changes how much of the thread routing and serialization consume before your handler even runs.

The event loop lag formula from the event loop page applies unchanged, with one addition: the blocking time bb per request now includes framework overhead, not just your handler.

btotal=bmiddleware+brouting+bhandler+bserializationb_{\text{total}} = b_{\text{middleware}} + b_{\text{routing}} + b_{\text{handler}} + b_{\text{serialization}}

Each term is small in isolation — a few hundred microseconds for routing, low milliseconds for serializing a modest JSON body — but they are additive and they all occupy the same thread. Twelve middleware functions and a large response body can add up to a meaningful fraction of your latency budget before your handler’s own logic runs at all, and none of it shows up as “my code is slow” in a profiler that only samples your route handler.

Connection count and CPU work are two different ceilings, and CPU work is the one that binds first in practice. libuv and the kernel’s readiness mechanism handle tens of thousands of idle sockets cheaply, as established on the event loop page — that ceiling exists (open file descriptors, ulimit -n, server.maxConnections if you set it), but it is rarely what a Node backend hits. server.maxConnections, when set, closes new sockets once the count is reached in a single-process server; in a cluster deployment each worker tracks its own count independently unless server.dropMaxConnection is used to have the primary stop routing to a worker before its own limit closes the socket. None of that changes the more common bottleneck: what a framework adds on top of the socket layer is CPU work per accepted connection, and that is the number that actually caps throughput long before the descriptor limit does.

When the workload is CPU-bound in aggregate, not just per-request. A service that does meaningful computation on every request — image resizing, PDF generation, non-trivial data transformation — will saturate the single thread regardless of which framework routes the request there. Route that work to worker threads or a separate service; no framework choice fixes a CPU-bound handler.

When you need compile-time route/schema safety and are choosing purely on that. Fastify’s schema validation happens at request time, not compile time — it catches malformed input, not a TypeScript type error. If the goal is static guarantees, that comes from TypeScript and a validation library (zod, typebox), not from the framework’s request pipeline.

When the team is already invested in a DI-based structure. If you want request-scoped providers, decorators, guards, and interceptors as a first-class concept rather than something bolted onto middleware, that is the layer covered on the dependency-injection frameworks page — built on top of exactly this model, with an added request lifecycle worth understanding on its own.

When you are choosing Express vs. Fastify based on the benchmark above. It shows the two are close on a trivial route, not that they are interchangeable at scale. Re-measure with your actual route count, payload shapes, and middleware stack before treating either number as representative.

Express is still the default in most existing Node services — its middleware ecosystem (helmet, cors, express-rate-limit, express-session) is the largest, and Express 5’s automatic async-error handling (2024) closed the gap that used to be the strongest argument for switching.

Fastify shows up where serialization and routing overhead is measurable at the team’s actual scale — high-RPS internal APIs, gateway services handling thousands of distinct routes, or services where response payload size is large enough that a compiled serializer’s savings compound.

Both sit in front of the same production symptom. A blocked event loop looks identical from the outside whether the blocking call is in raw http.createServer, an Express middleware, or a Fastify hook — see reading the symptoms for how that shows up on a dashboard. High latency with high CPU pegged on one core is this. High, flat CPU is not required to confirm it, though: a synchronous filesystem call (readFileSync against a slow disk or a network filesystem) blocks the thread for the syscall’s duration while the thread sits idle in a kernel wait state, so CPU can stay low even though the loop is fully stalled. Event loop lag, not CPU, is the metric that catches both cases.

Symptom: p99 latency is a hundred times the slowest individual operation, and CPU on one core is pegged. The queueing effect measured above. Nothing is individually broken — every request is legitimately doing its 20 ms of work — but they cannot run in parallel, so the queue behind a burst of concurrent requests grows exactly as M/D/1M/D/1 predicts. Fix: find and move the synchronous work, not the framework.

Symptom: a request hangs forever, no error logged, no timeout fires. Usually an early return in middleware that forgot to call next(). An Express 4 unhandled rejection inside async middleware is the other common cause, but its outcome depends on what else is installed: with no process.on('unhandledRejection', ...) handler, modern Node’s default is to throw and terminate the whole process — which ends every in-flight request abruptly rather than leaving this one hanging. The silent-hang version happens specifically when the app does have an unhandledRejection handler that logs and continues instead of exiting — then the process survives, but this one request never got a response and never will. Confirm with event loop lag monitoring — if lag is flat while the one request is stuck, it is not blocking, it is an abandoned chain.

Symptom: adding a schema/serializer sped up small responses but slowed down large ones, or vice versa. Compiling a schema into a serializer has a one-time setup cost that amortizes across the requests that route serves, not across the size of any single payload — JSON.stringify has no such compilation step, so the two approaches trade differently depending on response shape and request volume. Measure at your actual payload shapes and sizes rather than assuming a compiled serializer is a strict win.

Symptom: switching Express → Fastify “for performance” changed nothing. The measured comparison above is the reason: on routes that do little synchronous work, framework overhead was never the bottleneck. Check event loop lag under load before attributing a latency problem to routing overhead.

1. Diagnose from the numbers. A service reports p50 latency of 8 ms and p99 of 1,200 ms under load, with CPU on one core near 100%. Is this a framework problem?

Solution

No — the CPU signature says something on the thread is doing real work for a meaningful fraction of requests, and the gap between p50 and p99 is the queueing tax from that work blocking everyone behind it, exactly like the /block measurement above. Swapping frameworks changes the constant factor on routing and serialization, not the presence of a blocking handler. The fix is finding the synchronous operation — a large JSON.parse, an unoptimized regex, synchronous crypto — not the router.

2. Predict the numbers. Given the measured /block result (200 requests, concurrency 50, 20 ms of blocking work each, wall time 4,017 ms), what wall time would you expect at concurrency 200 with the same 200 total requests, and why would it barely change?

Solution

Roughly the same — about 4,000 ms — because the queue is bounded by total serial work (200 × 20 ms), not by how many requests are waiting simultaneously. Raising concurrency from 50 to 200 means more requests arrive already queued rather than trickling in, but the single thread still processes them one 20 ms slice at a time. What changes is the distribution: p50 rises sharply because the median request now waits behind more of the queue, while total wall time for the whole batch stays anchored to the sum of blocking time.

3. Fix the hang. This Express 4 middleware occasionally leaves requests open with no response and no error:

app.use(async (req, res, next) => {
const token = await verifyToken(req.headers.authorization);
if (!token) {
res.status(401).json({ error: 'unauthorized' });
}
next();
});
Solution

Two independent bugs. First, next() runs unconditionally, including after the 401 response has already been sent — the request continues into downstream handlers that assume req.user exists, and calling res.json again later throws ERR_HTTP_HEADERS_SENT. Second, if verifyToken rejects, Express 4 does not catch it, and neither next() nor a response ever fires — the request hangs until the client’s own timeout.

app.use(async (req, res, next) => {
try {
const token = await verifyToken(req.headers.authorization);
if (!token) return res.status(401).json({ error: 'unauthorized' }); // return stops the chain
req.user = token;
next();
} catch (err) {
next(err); // routes to error middleware instead of hanging
}
});

“Does the framework you use change Node’s concurrency model?”

No — Express and Fastify both run on the same single-threaded event loop. What changes is the constant factor per request: routing lookup, middleware dispatch order, and serialization cost. I measured this directly: a non-blocking route on Express and Fastify performed within noise of each other at low request volume, and a route with 20 ms of synchronous work collapsed both frameworks’ throughput identically, because the bottleneck was the single thread, not the router.

Where the frameworks genuinely differ is ergonomics around that model — Fastify’s schema-compiled serializer is measurably faster at scale, and Express 5 finally routes rejected async middleware to error handling automatically instead of hanging silently.

The caveats that signal you have run this in production:

  • A middleware chain hides synchronous work behind a layer of indirection — the profiler still finds it, but “which of my twelve middleware functions” is a slower first question than “is my handler slow.”
  • Express 4’s lack of automatic async-error handling is not a historical footnote if the service predates Express 5 — it is a live hang risk in any async middleware without an explicit try/catch.
  • I would not choose a framework based on a synthetic single-route benchmark; I would check event loop lag under my actual traffic shape, because that is the metric that tells you whether framework overhead is even the question worth asking.