Node Backend Frameworks
Assumes you have read: The Event Loop, Concurrency and Parallelism
Intuition
Section titled “Intuition”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.
Mechanics
Section titled “Mechanics”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);import Fastify from 'fastify';const app = Fastify();
const schema = { response: { 200: { type: 'object', properties: { id: { type: 'string' } } } },};
app.get('/users/:id', { schema }, async (req) => { return db.findUser(req.params.id); // schema-compiled serializer, not JSON.stringify});
app.listen({ port: 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.
What actually saturates first
Section titled “What actually saturates first”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 fetch → await 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: 2917ms200 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 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.
Cost & limits
Section titled “Cost & limits”The event loop lag formula from the event loop page applies unchanged, with one addition: the blocking time per request now includes framework overhead, not just your handler.
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 NOT to use it
Section titled “When NOT to use it”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.
Real-world usage
Section titled “Real-world usage”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.
Failure modes
Section titled “Failure modes”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 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.
Practice problems
Section titled “Practice problems”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 }});Interview answers
Section titled “Interview answers”“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
asyncmiddleware without an explicittry/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.