Get E-Book
API Design, Contracts & Frameworks

Express and Fastify Internals

Ishtmeet Singh @ishtms/June 10, 2026/45 min read
#nodejs#api-design#express#fastify

Both Express and Fastify sit on the same Node HTTP server lifecycle from Chapter 10. Both get an http.IncomingMessage and write back through an http.ServerResponse held somewhere inside the framework object. From there they split. Express runs a request as a walk through a registered stack of functions, and Fastify runs it as a matched route with a lifecycle wrapped around it. The difference comes down to two things. The state each framework builds before any request arrives, and the order it runs things in once a route matches.

None of that shows up in a route this small.

js
app.post('/users/:id', validateUser, async (req, res) => {
  const user = await updateUser(req.params.id, req.body);
  res.status(200).json(user);
});

In Express, validateUser is one function in a middleware stack. It receives req, res, and next. Calling next() moves Express on to the async handler. If the function sends a response instead, the stack stops there, because the request is already finished. Passing an error with next(err) makes Express skip ahead to error-handling middleware.

Fastify puts the same contract facts in route options.

js
fastify.post('/users/:id', { schema: userSchema }, async request => {
  return updateUser(request.params.id, request.body);
});

Here Fastify registered a route with a schema, hook slots, and a handler. When the request lands, the route matcher picks the route, lifecycle hooks fire in fixed positions, validation runs before the handler, and response serialization can run after the handler returns. The handler body looks smaller because the work moved onto the route record.

A web framework is the userland code that runs the API layer above node:http. It registers routes, matches incoming requests, runs extension points, reads and validates inputs, calls handlers, serializes outputs, and sends failures back down a response path. Express and Fastify both do that work, with different internals underneath. Express builds everything around the mutable request-response cycle, while Fastify builds it around a compiled route lifecycle.

The bugs come straight out of that difference. In Express, the usual culprit is middleware order, validation running wherever you happened to drop the validator in the stack, and error flow that depends on next() plus how the framework treats a returned promise. Fastify shifts the failure modes elsewhere. There the usual culprit is hook scope, validation running where the route schema dictates, and error flow that depends on which lifecycle phase you are in and on the reply state.

The same endpoint takes a different path through each framework.

text
Express:
request -> app stack -> matching layer -> middleware -> handler -> response

Fastify:
request -> route lookup -> hooks -> validation -> handler -> serializer -> reply

The rest of the chapter stays at the framework level. HTTP parsing, headers, bodies, streams, and response finalization were all covered earlier in the book. What is left to work out here is where framework state lives and when each piece runs.

Framework State Above Node HTTP

Node's HTTP server hands the framework a single request listener. That listener receives parsed HTTP metadata, a handle on the request body stream, and a response object. Everything a framework does runs inside that listener, once per request.

In Express, the application object is that listener. app.listen() spins up a Node HTTP server and passes the app in as the request listener, and you can wire it yourself with http.createServer(app). When a request comes in, the app function enters Express routing state, attaches Express-specific request and response behavior, and starts walking the stack that app.use(), app.METHOD(), and mounted routers built.

Fastify wraps its own server object around a Node HTTP server, and fastify.listen() starts it. The Fastify instance carries route registration methods, hook registration methods, decorators, schema storage, and plugin scope state. A request comes in through the server listener and immediately hits route lookup and the lifecycle engine.

TCP, TLS, HTTP parsing, keep-alive, and the raw response bytes all stay with Node and the operating system, and Chapter 10 already covered those objects. The framework request lifecycle starts only once Node has enough request state to call into JavaScript, and it ends when the framework has either sent a response or handed control to lower response finalization.

The framework lifecycle keeps state of its own.

text
registration state
  routes
  middleware or hooks
  schemas
  serializers
  plugin or router scope
text
request state
  matched route
  parsed params
  parsed body
  per-request fields
  reply status and payload

Registration state gets built while the process boots, and request state gets built fresh for each request. Telling the two apart pays off the moment you debug. A misplaced Express app.use() is a registration problem, and so is a missing Fastify decorator, which sits in encapsulated registration state. A bad JSON body is request state. A thrown async handler is request state spilling into framework error state.

The word handler means two different things across these frameworks. In Express, route handlers and middleware run on the exact same stack mechanics, so the handler is just the application function picked for a route. In Fastify, the handler is the user function that runs inside the route lifecycle, after parsing and validation.

Registering a route adds a route definition to framework state. That definition almost always carries an HTTP method, a path pattern, and a handler, and it can carry more - middleware, hooks, schema, body limits, or serialization rules. Matching is a separate piece. The route matcher is the runtime code and data structure that takes the incoming method and path, compares them against every registered route, and picks the one that wins.

Both frameworks compile some route-matching state at registration. The real difference is how much lifecycle state attaches to each route. Express keeps layered functions and matches them in registration order, whereas Fastify keeps route options grouped around a route record and runs a fixed lifecycle the moment lookup succeeds.

That gap in how much lifecycle attaches to each route explains most of what comes next.

One Request, Two Internal Paths

Here is one incoming request, the same for both frameworks.

text
POST /users/42 HTTP/1.1
content-type: application/json

Node already accepted the connection and parsed the HTTP message before any of this, and it reaches JavaScript through the HTTP server request listener. At that point the framework gets handed raw Node request and response objects, and framework state begins from there.

Express starts by entering the application function, which calls into the app router. The router holds a stack pointer, the request path, the HTTP method, and the current error state. There is no error yet, and the stack pointer sits just before the first registered layer.

From there it moves to the next layer and runs two checks, path match and method match. A middleware layer from app.use() might match a prefix or every path. A route layer from app.post('/users/:id', ...) checks both path and method, and a mounted router introduces another stack under a prefix. Express keeps fields like req.originalUrl, req.baseUrl, and the route params current, so nested routers still know which part of the URL belongs to them.

Every ordinary layer that matches gets req, res, and next. The function runs until it returns, throws, calls next, starts async work, or writes the response. next() is what advances the stack. A synchronous throw, or in Express 5 a returned Promise that rejects, drops it into error flow. A handler that just returns and does nothing leaves the request hanging, because nothing sent a response and nothing called for the next layer.

Most Express control-flow bugs come back to that one rule.

text
Layer 0: express.json()
Layer 1: validateBody(schema)
Layer 2: POST /users/:id handler
Layer 3: error handler

express.json() parses the body, calls next(), the router checks Layer 1. validateBody() accepts the parsed body, calls next(), Layer 2 runs. Layer 2 calls res.json(), response writing starts, and as far as the application is concerned the route is done. Express only keeps going if some code calls next() after that, which is exactly the double-completion bug.

An error changes the sequence. next(err) records error state and continues, but now the router skips ordinary three-argument middleware and route handlers and looks for four-argument error middleware. A synchronous throw reaches the same state, and in Express 5 so does a returned Promise that rejects. Express 4 apps relied on small wrapper functions for this, because that release line treated returned Promises differently.

So the Express route matcher is tied to the stack walk. A route can match and then step aside by calling next('route'), which skips the rest of that route's callbacks and resumes matching later route layers. Mounted routers trim and restore path state while control sits inside the child router. The request stays the same JavaScript object the whole time, and Express updates the fields around it as control crosses into the child router.

Fastify works in the other direction. The Node listener enters Fastify's routing function, and Fastify looks up the method and path first, before any request hooks for the matched route run. That lookup returns a route context - the handler, route options, a params parser, schema validators, serializers, hook arrays, logging configuration, and the encapsulated instance the route belongs to.

Then Fastify creates or fills its request and reply wrappers. The Fastify request keeps a pointer back to the raw Node request through request.raw, and the reply points to the raw Node response through reply.raw. Framework fields like request.params, request.query, request.body, request.routeOptions, and request.log sit on top of that lower state. Handlers work against the wrappers because that is where Fastify lifecycle state lives.

The lifecycle phase controls which fields exist.

text
route context found
  -> request and reply wrappers
  -> onRequest hooks
  -> body parser
  -> validation
  -> handler
  -> serializer

At onRequest, the route is matched and the request and reply objects exist, but body parsing is still ahead. The preParsing phase can still transform the payload stream. Once parsing finishes, request.body is populated for body-bearing requests that carry a supported content type. Hooks in preValidation can adjust the parsed input before schema validation runs. After that, the compiled validator runs against each configured request part, meaning body, query string, params, and headers.

Whatever validation produces is what the handler then sees. Coercion, applied defaults, stripped unknown fields, any one of those makes request.body diverge from the raw JSON parse, and that depends on the configured compiler and schema options. The handler reads framework-processed input that came from the parsed transport data.

The handler can return a value or call reply.send(). A returned value hands Fastify a payload for the reply path. If a response schema exists for the status code and content type, Fastify picks it, and the selected serializer turns the JavaScript value into the outgoing payload string. Before serialization, preSerialization can inspect or swap the payload for supported payload types. Right before the raw response is written, onSend gets a chance to inspect or change the outgoing bytes or string.

A Fastify request runs through more named checkpoints, and some bugs fire earlier than the point where they surface. Say a handler finds a number in request.query.limit when the client sent a string. That is validator coercion, which ran before the handler. A response that drops an internal field usually points to schema-based serialization, which ran after the handler returned. And a hook that fires for /admin but skips /users is almost always scoped to the admin plugin's encapsulation context.

The two traces share the same lower HTTP objects, then split apart.

text
Express:
raw req/res -> stack layer -> stack layer -> handler -> res helpers

Fastify:
raw req/res -> route context -> hooks -> validators -> handler -> serializer
Side by side comparison of the Express middleware stack with one shared error middleware and the Fastify fixed lifecycle phases with one shared error handler.
Express runs one registration-ordered middleware stack, and a throw or next(err) from any layer jumps to a single four-argument error middleware. Fastify runs fixed named lifecycle phases, and a throw, rejection, or failed validation routes to one errorHandler.

Neither framework changes the lower rule from Chapter 10. An HTTP response still gets a single final status, a single header set, and one body stream lifecycle. The framework helpers make common writes easier to express, and they all still resolve down to http.ServerResponse.

Express as a Stack

Express is a routing and middleware framework for Node HTTP APIs, and its request model is one ordered stack of layers. Each layer pairs a set of matching rules with a function. Those layers come from a few places - app.use(), the method-specific calls like app.get() and app.post(), and Express routers mounted under a path.

That stack belongs to the Express router, one per app or router instance. Calling express.Router() gives you a fresh stack that can be mounted into another stack. Mounting adds a path prefix and lets the child router handle the rest of the path whenever the prefix matches.

A small model of the Express internals covers what you debug day to day.

text
Layer {
  path matcher
  optional route method data
  handle function
}

The real implementation carries more fields and detail, but that small model explains the behavior you actually debug. When a request enters Express, the router scans layers in registration order. A layer with no route method can match many methods, while a route layer checks the method too. Path matching decides whether a layer takes part, and if it does, Express calls the function stored on that layer.

That function receives familiar objects.

js
app.use((req, res, next) => {
  req.startedAt = Date.now();
  next();
});

A middleware function can mutate req, mutate res, call next(), call next(err), or finish the response itself. The middleware stack is just that ordered set of functions plus one rule, that next() advances to the next matching function. Order is part of the contract. There is no separate planning phase that reorders the stack for you.

Route handlers play by the same rules.

js
app.get('/users/:id', loadUser, (req, res) => {
  res.json({ id: req.params.id, name: req.user.name });
});

loadUser runs ahead of the final handler purely because it was registered ahead of it for that route. It can attach req.user, reject the request, or pass control along. The final handler writes the JSON response. Whichever function writes the response finishes the request.

Path parameters come straight from the matched route pattern. /users/:id against /users/42 fills req.params.id. Query and body parsing are separate middleware concerns. req.query comes from Express query parsing. req.body only shows up after body-parsing middleware has read and parsed the body. Register express.json() after a route and that route runs against the old state.

js
app.post('/users', (req, res) => {
  res.json({ body: req.body });
});

app.use(express.json());

That route sits ahead of the JSON parser in the stack. When the handler runs, the request body stream can still hold unread bytes. The bug here is registration order. Node already handled HTTP parsing, and Express is only walking the stack it was handed.

The fix moves parsing ahead of every route that needs parsed JSON.

js
app.use(express.json());

app.post('/users', (req, res) => {
  res.json({ body: req.body });
});

Now every matching request goes through the JSON parser first. On success, the parser sets req.body and calls next(). A parse failure goes down the error path instead. A body over the configured limit gets rejected before the handler ever sees application data.

A real route usually gives each middleware one job.

js
app.post('/users/:id',
  express.json(),
  validateUser,
  saveUser
);

express.json() handles body parsing. validateUser runs the API contract check on the parsed body. saveUser does the application work and picks the response. Express sees three functions and nothing more. Their meaning comes from how the team orders and names them.

The validator can store the checked value on the request.

js
function validateUser(req, res, next) {
  const result = userValidator(req.body);
  if (result.error) return next(result.error);
  req.validUser = result.value;
  next();
}

The handler then reads req.validUser, and req.body stays as the raw external data.

js
async function saveUser(req, res) {
  const user = await updateUser(req.params.id, req.validUser);
  res.status(200).json({ id: user.id, email: user.email });
}

This is a plain Express pattern. Middleware turns external data into application-ready request state, and the handler reads that state back out. To the framework, req.body and req.validUser are both just application fields. The meaning of the safer field lives in tests, TypeScript declarations, and route module conventions.

The error path completes the same request contract.

js
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);
  res.status(err.statusCode ?? 500).json({ error: 'request_failed' });
});

Register the error middleware after the routes it covers. The stack walk reaches it only once an error has entered request state. It can inspect validation errors, parser errors, and handler errors, though the actual response contract for those is the next subchapter's job. What controls all of this is placement. Error middleware registered earlier catches earlier failures, and error middleware registered later only catches whatever got passed down the stack to it.

Mounted routers add one more layer of registration state. A router is its own stack, with its own middleware and route layers. Mount it under a prefix and you create a parent layer that delegates into the child stack whenever the prefix matches.

js
const users = express.Router();

users.get('/:id', showUser);
app.use('/users', users);

For GET /users/42, the app stack matches the /users mount layer, then the child router matches /:id. Express keeps the original URL reachable the whole time the child router runs against its local path. This is what makes it possible to write mounted routers as small route modules and leave the final prefix to the parent app.

Router-local middleware follows the same order rule.

js
users.use(loadUserContext);
users.get('/:id', showUser);
users.patch('/:id', updateUser);

loadUserContext runs for any route that passes through the users router once the mount prefix matches, and it stays local to that router's stack. That helps when you want to split an API into local sections. It also causes a common bug, where a team puts validation or auth on one router while a second router still exposes a nearby path without it.

Express route callbacks can also form a tiny stack inside one route.

js
app.get('/users/:id',
  loadUser,
  requireVisibleUser,
  sendUser
);

All three callbacks belong to the same route layer's handler list. next() steps from loadUser to requireVisibleUser to sendUser. next('route') skips the rest of that route's callbacks and goes back to matching later route layers. You rarely need that option in clean API code, but it surfaces when you read older Express apps that define the same method and path more than once.

Per request, an Express middleware function has exactly one job. It either finishes the response or it continues. Finishing means writing the response through res.send(), res.json(), res.end(), res.redirect(), or a stream pipe that ends the response. Continuing means a single call to next(), or next(err) when there is an error to pass along.

Call next() twice and the whole thing breaks down. The stack advances twice, so a later handler can write a response after an earlier one already did, or an error handler can run after a success response already started. Express guards some of this through res.headersSent, and Node enforces header-mutation rules once headers are out, but by then the application has already built conflicting control flow.

Error-handling middleware is an Express middleware function with four parameters.

js
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);
  res.status(500).json({ error: 'internal_error' });
});

The four-argument form is part of Express routing itself. Once a request carries an error, Express skips ordinary middleware and route handlers until it reaches error-handling middleware. If res.headersSent is true, the error handler delegates, because the status and headers are already committed. At that point the default Express error handler can close the connection for a failure that showed up after response writing started.

Express 5 also fixed a long-standing async problem. A route handler or middleware that returns a rejected Promise, an async function that throws included, drops into the next(err) path on its own. Older Express 4 code wrapped async handlers by hand to get this. Express 5 code can rely on returned rejections reaching framework error flow. Errors from detached async work still have to be reported back to Express yourself.

js
app.get('/users/:id', async (req, res) => {
  const user = await loadUser(req.params.id);
  res.json(user);
});

If loadUser() rejects in Express 5, the router runs the error path with that rejection. The handler handed Express a Promise, so Express can watch it settle. A timer callback, a background task, or an event listener created inside the handler runs later, after the handler already returned. Express can only handle those failures when your code reports them back through request-aware control flow.

Express stays close to plain JavaScript functions, and that is what makes it flexible. The cost is that the application carries more lifecycle policy in its ordering, conventions, and local discipline. A validator is only middleware until you give it contract meaning, and the same goes for a logger until you decide where it has to run. Auth, rate limiting, and correlation ID middleware all run on the same stack mechanics, even though their deeper design lands in later chapters.

Fastify as a Lifecycle

Fastify builds the request lifecycle at registration rather than per request. A route is a method, a URL pattern, an options object, and a handler. Those options carry the rest, schema, hooks, constraints, body limits, config, and the handler itself. Registering a route turns all of that into state Fastify reads back once a request lands.

The smallest route looks like any other framework route.

js
fastify.get('/users/:id', async request => {
  return getUser(request.params.id);
});

The request hits route lookup first. Fastify matches method and path, pulls out the params, and finds the route record. Then the request and reply hooks fire in their fixed lifecycle positions. A lifecycle hook is a function you register for one phase. It can read or change request state, stop the request by sending a reply early, or fail the request by throwing or by passing an error through the callback form.

Most requests run the same trace.

text
incoming -> routing -> onRequest -> preParsing
parsing -> preValidation -> validation
preHandler -> handler -> preSerialization
onSend -> outgoing response -> onResponse
The Fastify request lifecycle as ordered phases from routing to onResponse, with notes on what is available at each phase and error exits to the errorHandler.
Each Fastify phase runs in a fixed order with known state. request.body is empty at onRequest and populated by preValidation, validation runs Ajv, and a failed schema or a thrown handler routes to one errorHandler that serializes the error.

Fastify has more hook positions than most handlers ever touch, and where each one sits is what gives it meaning. onRequest comes before body parsing, so request.body is still empty there. The preParsing hook can transform the incoming payload stream before Fastify parses it. By preValidation, parsing is done but schema validation has not run yet. preHandler sits after validation, right before the route handler. Once the handler hands back a payload, preSerialization runs before serialization, and onSend can read or change the outgoing payload after that. Last, onResponse runs once the response has gone out.

That fixed order narrows down where to look when something breaks. A missing body during onRequest is normal for that phase. A tenant hook that has to run before validation belongs in preValidation or earlier, depending on what data it reads, while a hook that needs to wrap the handler belongs in preHandler. If you are stripping fields from JSON output, that work belongs in the schema or the serializer, which makes an early request hook the wrong place for it.

Fastify hooks can use callbacks or async functions.

js
fastify.addHook('preHandler', async request => {
  request.user = await loadUser(request.headers.authorization);
});

An async hook returns a Promise that Fastify waits on. A callback-style hook instead gets a continuation callback to call when it finishes. Pick one style per hook. Mixing async with the callback form in the same hook risks completing it twice.

Schemas ride on the route too, and they drive both validation and serialization.

js
fastify.post('/users', {
  schema: userRouteSchema,
}, async request => {
  return createUser(request.body);
});

The schema object can describe body, querystring, params, headers, and response. Those request parts feed validation, and the response part feeds serialization. By default Fastify validates with Ajv, a JSON Schema validator, and serializes JSON through compiled serializers when a response schema exists. You can swap the compiler, but the framework-level contract holds. The route carries its runtime schema work as part of the lifecycle.

The route can also carry local hooks.

js
fastify.post('/users/:id', {
  schema: userRouteSchema,
  preHandler: [loadActor],
}, async request => {
  return updateUser(request.params.id, request.body);
});

Registered as preHandler, loadActor runs after parsing and validation, so it sees a validated tenant parameter if the route schema produced one. A lookup that instead needs raw headers can move to an earlier hook that runs before parsing. Where you place the hook is part of the route contract.

A compact schema object shows the two sides.

js
const userRouteSchema = {
  body: userBodySchema,
  params: userParamsSchema,
  response: { 200: userResponseSchema },
};

body and params feed request validation, while response feeds reply serialization. A handler can return an object with more fields than the API exposes, and the serializer keeps only the contract-facing structure for that status code.

A validation compiler takes schema context and returns a validator function that runs against request data at request time. A serializer compiler does the parallel job on the way out, taking response schema context and returning a function that turns a JavaScript value into a string payload. Both fall under the term schema compiler. Either way, the route schema becomes executable code before any live request touches it.

Fastify feeds context into those compilers. A validator compiler can receive the schema plus the HTTP part it is validating - body, params, query string, or headers. A serializer compiler can receive the response schema plus the method, URL, status code, and content type. With that context, one compiler can build different functions for different route positions and the route code stays small.

Fastify acts on whatever your validator returns, because it expects a value-or-error result.

js
fastify.setValidatorCompiler(({ schema }) => {
  return data => validateAgainst(schema, data);
});

validateAgainst() has to return the result format Fastify was configured to expect. On success it hands Fastify the validated data. On failure it hands back validation error data, and Fastify can attach request validation context before entering the error response path. Throw out of the validator instead, and you blur a compiler bug together with a normal validation failure.

Serializers have a different contract.

js
fastify.setSerializerCompiler(({ schema }) => {
  return data => serializeWith(schema, data);
});

The returned function produces a string payload from a JavaScript value. It runs on the response path, after the handler has produced data and after Fastify has picked the response schema. A slow serializer here slows down requests that already succeeded, and a broken one turns a good handler result into a framework error after all the application work is done.

That route-local contract is the main difference from bare Express. In Express a validator is usually just one more middleware function, whereas in Fastify validation becomes a named lifecycle step that comes out of the route options. You can still hand-roll custom validators, hook-based checks, or plugins, but the default path gives schemas a fixed slot.

Replies work differently from Express responses, too. A handler can call reply.send(value) or just return a value. Return one and Fastify continues the reply path on its own, picking a serializer by status code and content type, serializing the payload, running onSend, and writing the response. Calling reply.send() follows the same completion path, just written out explicitly.

js
fastify.get('/health', async () => {
  return { ok: true };
});

The returned object becomes the reply payload. If a response schema exists for that status code, Fastify can run the compiled serializer for it. Properties the schema leaves out can drop from the serialized output, depending on the schema and serializer behavior. That is a contract decision, and it is visible to clients. The object your handler returns and the bytes your client receives can differ on purpose.

The route matcher has priority rules of its own. It checks static routes before parametric and wildcard ones. Parametric routes pull values into request.params, and wildcard and regular expression routes are available too, where the pattern you pick affects both lookup cost and ambiguity. All of this makes route specificity part of registration state. When clients depend on stable behavior, keep ambiguous patterns out of the same prefix.

Route options also give Fastify one place to keep behavior that Express apps tend to scatter across files. A single route can carry schema, route-local hooks, body limits, logging options, and custom config. Plugins can add hooks that cover many routes at once. So the matched route context holds far more than a function pointer. Inspect a Fastify request and the handler turns out to be only the last userland function in an already-prepared route context.

All of this means a Fastify route can fail before the handler logs a single line. Routing fails when nothing matched. Parsing rejects a bad content type or bad body bytes. Validation throws out a params, querystring, headers, or body that breaks the schema, and a hook can fail somewhere ahead of the handler. Through all of that, the handler itself might be correct and never even run.

Route conflicts make the matcher difference show up. Express routing follows stack order, so a broad parameter route can capture a path that a later static route was meant to handle.

js
app.get('/users/:id', showUser);
app.get('/users/me', showCurrentUser);

For GET /users/me, the first route matches with req.params.id === 'me'. Once showUser sends a response, the request is finished before the later static route ever gets a turn. The fix is registration order, or a narrower pattern.

js
app.get('/users/me', showCurrentUser);
app.get('/users/:id', showUser);

Fastify's matcher gives static routes priority over parametric and wildcard ones, so a static /users/me gets checked ahead of /users/:id. That saves the route from stack-order drift. It also means porting from Express to Fastify can change which handler wins a conflicting path. Pin the public route behavior in tests, because the new matcher can hand the path to a different winner than the old accidental one.

Catch-all paths bring the same trouble. A broad Express middleware mounted early can take a request before any later route sees it, and a broad Fastify wildcard can make a prefix hard to reason about even with static priority in play. Keep broad matches at the outer edge, where they fit - 404 handling, static file serving, or an explicitly scoped proxy route.

Registration Time and Request Time

Framework setup cost and request cost live in different places.

Express registration mostly builds stacks. app.use() appends a middleware layer, app.get() appends route state and handler functions, and express.Router() makes a nested stack you can mount later. Some path-matching state does get prepared at registration, but the app still relies on a request-time scan through the matching layers, in order.

Fastify registration builds more than that. Route records, hook arrays, schema links, serializer links, decorators, and plugin scopes. Before the server takes any traffic, Fastify gets a chance to finalize plugin registration and route state. Schema compilers can turn JSON Schema into validator and serializer functions ahead of the first live request. When the schema is known at setup, that work moves off the hot request path.

The split is easier to see with a route that has a body schema and response schema.

js
const schema = {
  body: userBodySchema,
  response: { 201: userResponseSchema },
};

fastify.post('/users', { schema }, async (request, reply) => {
  reply.code(201);
  return createUser(request.body);
});

At registration, Fastify stores the route and schema references in the route options. The compilers then produce executable validators and serializers from those schemas during startup and route preparation. By request time, Fastify can run the already-built validator against request.body, call createUser, and serialize the result through the response serializer it selected for the reply status.

Express can run the same contract, but the work is usually wired manually.

js
app.post('/users',
  express.json(),
  validate(userBodySchema),
  createUser
);

Here the schema behavior lives in validate(), and the framework only sees middleware functions. Compile the schema at module load and the setup cost is paid once. Compile it inside the middleware body and every request pays the cost again. Express gives you the stack slot, and your code or library decides when compilation runs.

This is the part benchmark arguments tend to miss. The mechanism decides the cost. A Fastify route with a schema can front-load more work before traffic arrives. An Express route can do the same one-time setup if you write the middleware for it. A Fastify route can also waste request time if a hook recompiles data on every call. The framework pushes you one way, but the application still decides the final cost.

Registration-time mistakes look different from request-time mistakes.

text
registration-time bug:
  middleware registered after route
  schema registered in sibling scope
  hook attached under wrong plugin
  serializer compiler changed too late
text
request-time bug:
  invalid body
  rejected handler Promise
  response already sent
  per-request field missing

Use that split when you read a stack trace. A validation error on a single request is request state, while a validator missing from every request to a route is registration state. A hook that fires for one prefix and skips another usually points to Fastify encapsulation. An Express handler that sees req.body as undefined usually comes down to middleware placement, the request Content-Type, or a body-parsing limit.

Fastify Encapsulation Scope

Every Fastify plugin opens a local scope. Encapsulation means that anything you register inside a plugin - hooks, decorators, schemas, nested plugins - applies to that plugin context and everything below it. A child can read its parent's context, but its own registrations stay on its branch, and two sibling plugins never share that local state.

Each of those scope nodes is an encapsulation context, and it holds the local registrations that routes in that context can reach. A decorator is a named property or method you attach to a Fastify instance, request, or reply through the decoration APIs. Decorators let a plugin give the routes in its context some local capability.

The bug that comes out of this usually looks harmless.

js
fastify.register(async app => {
  app.decorate('repo', usersRepo);
  app.get('/users/:id', getUser);
});

fastify.get('/health', health);

The /users/:id route can read the repo decorator, because it lives in that plugin context. The /health route was registered up in the parent context, so it only has parent state. The plugin's local decorator sits below it in the tree. Have health read fastify.repo and it looks in the wrong scope, because the decorator was local by design.

Hooks follow the same scope rules.

js
fastify.register(async app => {
  app.addHook('preHandler', requireTenant);
  app.get('/reports', listReports);
}, { prefix: '/tenants/:tenantId' });

requireTenant runs for routes in that plugin context and its descendants, and it skips unrelated routes on the root instance. That is what you want for a tenant-specific API. It also confuses a team that meant to register a global hook and did not. The fix is about placement rather than hook code. Put global hooks on the root instance, registered before the routes they cover, and keep local hooks inside the plugin that holds those routes.

Schemas are scope-sensitive too. Add a shared schema in one context and it is reachable inside that context and below it. A route registered somewhere else can miss the lookup entirely, or pick up a different schema that happens to share the name, depending on how the app is laid out. So schema placement becomes part of the contract.

The call that opens a child context is usually register(), often with a prefix passed alongside it. A plugin can take /admin, /internal, or /tenants/:tenantId and keep its hooks, decorators, schemas, and route options right next to the routes, which keeps your reasoning local. The tradeoff is that knowing a hook was registered somewhere no longer tells you much. The exact context is what decides behavior.

The same local-state rule covers per-request decorators. Fastify blocks a shared object reference on a request decorator, because that one object would end up shared across many requests. Initialize the per-request value inside a hook instead.

js
fastify.decorateRequest('locals');

fastify.addHook('onRequest', async request => {
  request.locals = { requestId: crypto.randomUUID() };
});

Now each request gets its own locals object during onRequest. The decorator declares the property name on the request object, and the hook fills it with a fresh value per request. Request state stays separate, and handlers still get a property name they can count on.

Express has local state too, but the scoping model is not the same. Routers mount under paths, and middleware attaches to app-level or router-level stacks. A mounted router carries its own stack, though the parent app still decides where that router sits. There is no Fastify-style encapsulation tree of decorators, schemas, hooks, and plugins. Express apps usually rely on module structure and router instances to stay organized, while Fastify builds scope into the framework model itself.

Where Validation and Serialization Run

Subchapter 02 covered where validation sits. External input arrives at the service, and runtime validation decides which structure the handler actually receives. The framework decides where in the request path that check runs.

Express leaves that placement to the stack. A typical route runs body parsing, then validation, then the handler.

js
app.post('/users',
  express.json(),
  validateBody(userBodySchema),
  async (req, res) => {
    res.status(201).json(await createUser(req.body));
  }
);

express.json() reads the body stream and parses the JSON. validateBody() checks req.body and either calls next() or sends back an error. The handler only sees validated data when every earlier middleware held that rule. Move the validator below the handler and the handler gets raw external data. Register another route above it that matches first and the validator never runs at all.

Response serialization in Express usually happens right in the handler. res.json(value) serializes using whatever JSON behavior Express and your settings pick. Mapping the response to a fixed structure, or checking it against one, takes middleware, helper functions, schema-aware response utilities, or plain discipline in the handler.

js
res.status(201).json({
  id: user.id,
  email: user.email,
});

That is readable, since the handler picks the response representation right there. It also leaks fields the moment someone passes a whole domain object instead. Express serializes whatever you hand it. If the contract says the response carries only id and email, the handler or a response helper has to enforce that itself.

Fastify can move both request validation and response serialization into the route options.

js
fastify.post('/users', {
  schema: {
    body: userBodySchema,
    response: { 201: userResponseSchema },
  },
}, async (request, reply) => {
  reply.code(201);
  return createUser(request.body);
});

The body schema validates request.body before createUser runs. The handler sets the reply status to 201, so Fastify reaches for the 201 response serializer. Define a 201 schema but return a default 200 reply, and Fastify looks for a 200, 2xx, or default response schema instead, using one if it exists. When validation fails, Fastify answers with a 400 through its validation error path. And when the handler returns extra properties against a schema-based serializer, the output follows the response schema and can come out different from the raw object.

The validation compiler decides how a request schema turns into a validator. By default Fastify uses Ajv with its own default options. A custom compiler can swap in another library, as long as it returns the validator result Fastify expects. A validator should hand back validated data or an error object. Throwing from validator code creates failures that mix badly with async hooks and unhandled-rejection behavior.

The serializer compiler decides how a response schema turns into a string-producing function. For JSON response schemas Fastify can use fast-json-stringify. Selection runs off the status code and content type. A route can define response schemas for 200, 201, a range like 2xx, or a default. The handler stays on application data, and the serializer produces the wire representation.

That split changes how contract drift behaves. In Express, drift appears when the handler output and the OpenAPI response schema slowly diverge, and some helper, response mapper, or test has to catch it. Fastify can instead govern the output of every response through a response schema. Drift can still slip in there if the schema is wrong, missing, sitting in the wrong scope, or skipped by direct reply behavior.

Runtime validation also marks the limit of what TypeScript can do. TypeScript describes what your code expects at compile time. The request body still arrives as runtime data with no compile-time guarantee behind it. Both frameworks need real runtime validation at the point where data enters, and they differ only in where it comes from. Fastify ships a built-in schema slot, while Express usually pulls in middleware from your own code or a validation library.

Coercion and unknown-field policy land here too. Fastify's default Ajv setup can coerce some input types and fill in defaults, depending on the schema and the options. Express validation middleware can do the same when the library you picked does. The contract should state which behavior to expect, because a handler that gets { limit: "10" } and a handler that gets { limit: 10 } are working on different runtime values.

Response validation and response serialization are two separate decisions. A serializer emits output according to the schema, while a response validator detects that the handler output broke the schema. Some setups do one, some do both, and plenty rely on tests instead. Keep the two ideas apart. Schema-driven serialization keeps extra fields away from clients, and output validation catches the handler drifting from the contract. They solve different parts of the problem.

Async Handler Rejection

An async handler that rejects hands the framework a Promise rejection instead of a response. The framework then has to turn that rejection into request error flow.

In Express 5, returned handler rejections automatically call the error path.

js
app.get('/users/:id', async (req, res) => {
  const user = await loadUser(req.params.id);
  res.json(user);
});

If loadUser() rejects, Express sees the returned Promise settle to a rejection and treats it like next(err). Error-handling middleware then decides the response, and the custom error response structure is the next subchapter's topic. For now, what counts is that a returned async failure reaches Express error middleware on its own.

Callback-style asynchronous code has a different failure point.

js
app.get('/file', (req, res, next) => {
  fs.readFile('missing.txt', (err, data) => {
    if (err) return next(err);
    res.send(data);
  });
});

The readFile callback fires later, after the handler call already returned. Express only learns about the error because the callback calls next(err). A throw inside that callback would happen outside the original synchronous handler call, where Express cannot catch it. The callback has to report the error back through Express control flow.

Fastify treats thrown errors and rejected Promises from async handlers and hooks as framework errors as well.

js
fastify.get('/users/:id', async request => {
  return loadUser(request.params.id);
});

A rejection from loadUser() drops into Fastify's error handling path. A hook rejection does the same for whatever phase it happened in. Validation failures take the validation error path, which runs before the handler. Serialization failures land after the handler and before the final response write, so what Fastify can still do depends on how much response state is already committed.

Response-already-sent is the hard case. Once the headers or body bytes have left for the client, the framework can log, close, or delegate, but it can no longer swap the sent status line and headers for a clean JSON error envelope. Express exposes this as res.headersSent, and Fastify's reply object tracks its own sent state. A handler that sends and then throws has produced two outcomes for one request.

js
app.get('/bad', async (req, res) => {
  res.json({ ok: true });
  throw new Error('late failure');
});

The success response is already on its way. The late failure still enters Express error flow, but the error handler has almost nothing left to work with. Try to send JSON from there and Node rejects the header mutation or the write-after-completion. The fix is to return right after sending, or to arrange control flow so the throw happens before the response is finalized.

Fastify has the same class of problem.

js
fastify.get('/bad', async (request, reply) => {
  reply.send({ ok: true });
  throw new Error('late failure');
});

The reply was sent outright, and the throw comes after. Fastify can deal with the error internally, but the client-facing response is already chosen. Treat return reply.send(...) or return value as the final step in the handler, and keep any later work out of the request response contract.

Error taxonomy, retry policy, and custom error classes are Chapter 27's job. The framework internals come down to one rule. If you want the framework to control the response, the error has to reach it before the response is committed.

Per-Request State

You often need to attach a bit of data to the current request for handlers and hooks to read later - a parsed actor, a tenant id, a request id, a loaded database row, or a small locals object. That is per-request state. The deeper observability version of request context is Chapter 29. For now the state lives on framework objects or in handler arguments.

Express commonly uses req and res.locals.

js
app.use((req, res, next) => {
  res.locals.requestId = crypto.randomUUID();
  next();
});

res.locals is scoped to the response object for that one request, and later middleware and handlers can read it. Hanging custom fields straight off req works in plenty of codebases too, but it asks for naming discipline, plus TypeScript declarations if the project uses TypeScript.

Fastify commonly uses request decorators and hooks.

js
fastify.decorateRequest('requestId');

fastify.addHook('onRequest', async request => {
  request.requestId = crypto.randomUUID();
});

The decorator names the property on Fastify request objects, and the hook fills it once per request. That keeps per-request data sitting in request state, and it keeps the plugin scope rules in view. Register the decorator inside a plugin and routes outside that context end up with a different request object structure.

Handler arguments carry state as well. In Express every middleware gets the same req and res references for a given request. In Fastify, hooks and handlers get Fastify request and reply objects for that request instead. Both frameworks expect you to mutate those objects for local request state. That holds up fine as long as the fields are small, per-request, and documented in the app.

The hazard is sliding from framework-local state into hidden global state. A module-level variable you mutate during a request is shared across every concurrent request in the process, and a singleton used as request scratch space behaves the same way. A request field, a reply local, or a hook-initialized object gives each request storage of its own.

Correlation IDs are one example. The framework can read or generate a request id and attach it to the request object. Carrying that id across async calls, logs, traces, outbound requests, and other services is the later observability topic. The only framework concern at this stage is placement. Generate the id early, store it on request-local state, and read it from whichever handlers need it.

Choosing by Mechanism

Express and Fastify are both sound choices for a Node API. The framework name says less about your code than the mechanism behind it.

Express gives you a small routing and middleware core, plus a wide set of libraries that all follow the same stack convention. A request runs through functions in registration order, so the control flow is visible in the order of the code. It also means validation, serialization, context, auth, rate limiting, and error handling stay only as consistent as your middleware placement and local conventions.

Fastify hands you route records with lifecycle hooks, schema slots, serializers, plugin scope, and decorators. A request runs through route lookup and then fixed lifecycle phases, which gives validation and serialization named positions in the framework. It also means plugin scope and hook placement become part of the model you carry in your head.

Migrating between them is more than a syntax change. Express middleware built around req, res, and next ports badly to Fastify hooks when it relies on arbitrary stack placement. The reverse is true too, since Fastify plugins built on encapsulation, decorators, and schema compilers port badly to plain Express routers. The handler body might move over in minutes, but the lifecycle policy takes the real work.

Choose Express when the team wants stack-level control, already has a pile of middleware, or wants the framework to stay quiet about schemas and plugins. Fastify fits better when the team wants route-local schemas, compiled validation and serialization, lifecycle hooks, and scope rules built into the framework. Both are mechanism criteria, the kind of local engineering inputs you can actually reason about.

The debugging posture changes with the framework.

text
Express debugging check:
  layer reached, completion path, error path

Fastify debugging check:
  matched route, applied scope, failed lifecycle phase

The real difference lands in debugging. Express bugs usually reduce to stack order, completion, and error middleware. Fastify bugs reduce to a longer list - route options, hook phase, schema compiler behavior, serializer selection, and encapsulation context. Once you can trace those, the framework stops being opaque, and it reads as ordinary request-processing state.