GAP·MAP

Node web frameworks

backend · Node.jsmiddlesenior~7 min read

covers Express middleware model · Fastify schema & plugins · NestJS DI & modules · guards, interceptors, pipes · choosing a framework · body limits & timeouts

[ middle depth ]

Express, Fastify, and NestJS at a glance

Three frameworks dominate Node interviews, and they sit at different layers. Express is a thin, unopinionated middleware runner. Fastify is a schema-first framework built for throughput. NestJS is a structure layer (dependency injection, modules, decorators) that runs on top of Express or Fastify.

As of mid-2026 the current majors are Express 5 (5.1 has been the default npm install express since March 2025), Fastify 5, and NestJS 11.

How Express handles a request

The one framework-level fact to state: Express 5 forwards a handler's returned rejecting promise to next(err) for you, whereas Express 4 does not. In v4 a rejected promise inside an async handler never reaches your (err, req, res, next) error middleware unless you route it there yourself. The node-server-patterns module owns the middleware-execution model, the worked example, and the wrapper fix.

Express gives you routing and middleware and stops there. Validation, serialization, and a project structure are all your choice, which is the freedom people like and the discipline they miss.

Why Fastify wants a schema on every route

Fastify's core idea is to attach a JSON Schema to each route. You describe the shape of the body, params, querystring, and the response, and Fastify does two things with it.

fastify.post("/users", {
  schema: {
    body: {
      type: "object",
      required: ["email"],
      properties: { email: { type: "string" }, age: { type: "integer" } },
    },
    response: {
      200: { type: "object", properties: { id: { type: "string" }, email: { type: "string" } } },
    },
  },
}, handler);

On the way in, Fastify validates the body against the schema and returns a 400 if it fails, so bad input never reaches your handler. On the way out, it serializes the response using only the fields named in the response schema. A property you did not list (a password hash, an internal flag) never reaches the client.

Wrong "Fastify is just a faster Express, so I can port my routes over unchanged." Right the programming model is different. You register functionality as plugins, and each register() call is encapsulated, so a decorator or hook added inside a plugin is scoped to that plugin. The senior notes cover how that scoping bites.

What NestJS adds: DI, modules, decorators

NestJS is not a competitor to Express in the same layer. It is an opinionated framework that wraps an HTTP engine (Express by default, or Fastify through an adapter) and adds a dependency-injection container. You write classes and let Nest wire them together.

@Injectable()
export class UsersService {
  constructor(private readonly db: Db) {} // Nest injects Db for you
}

@Controller("users")
export class UsersController {
  constructor(private readonly users: UsersService) {}
  @Get(":id") get(@Param("id") id: string) { return this.users.find(id); }
}

Providers, controllers, guards, and pipes are grouped into modules, and Nest resolves the dependency graph at startup. The payoff is structure that scales across a large team; the cost is boilerplate and a learning curve.

Wrong "NestJS is a heavier, slower Express." Right Nest runs on Express or Fastify, so it is a structure decision, not a speed one. Swap its adapter to Fastify and the controllers do not change.

Choosing a framework

A rough guide, not a rule:

  • A small service or a team that wants full control: Express.
  • Throughput matters and you are willing to write schemas: Fastify.
  • A large codebase or team that benefits from enforced structure and DI: NestJS (on either engine).

Whatever you pick, the request-size limit and timeouts still apply. Express body parsers default to a 100kb limit and reject a larger body with a 413; set an explicit limit on any framework rather than trusting the default.

[ senior depth ]

How Fastify encapsulation scopes plugins

Every fastify.register() call opens a new encapsulation context. Decorators, hooks, and plugins registered inside it are visible to that context and its descendants, but not to its parent or its siblings. This is the single most common Fastify surprise: you decorate the instance inside a plugin, then a route registered elsewhere reads fastify.db and gets undefined.

fastify.register(async (instance) => {
  instance.decorate("db", pool);   // scoped to THIS context and its children
});
fastify.register(routes);          // sibling context: routes.db is undefined

The escape hatch is fastify-plugin. Wrapping a plugin with it sets skip-override, which tells Fastify not to create a child context, so the decorator is applied to the parent instead and becomes visible to siblings.

import fp from "fastify-plugin";
export default fp(async (fastify) => {
  fastify.decorate("db", await createPool()); // now hoisted to the parent scope
});

Wrong "A decorator on the instance is global, so any route can use it." Right it is global only within the context that registered it and below. Share a connection or an auth helper across the app by wrapping its plugin in fastify-plugin, or by registering the consumers inside the same subtree. Encapsulation is a feature: it keeps a plugin's internals from leaking into unrelated routes.

Why Fastify benchmarks faster than Express

None of the wins is threads. Routing uses a radix tree (find-my-way): a lookup follows the shared prefix of the path instead of testing route patterns one by one down a list, so match cost barely grows with the number of routes. On top of that, the response schema is compiled by fast-json-stringify into a serializer specialized to that exact object shape, which skips the per-value type inspection JSON.stringify does on every call. Validation and coercion run through Ajv functions compiled once at boot rather than ad hoc checks per request. Fastify's logger (pino) is also off by default, trimming a little overhead.

Wrong "Fastify runs handlers in a thread pool, so it does not block like Express." Right Fastify runs on the same single-threaded event loop. A synchronous or CPU-bound handler stalls it exactly as it would stall Express. The wins are precompiled routing, validation, and serialization, not parallelism. The event-loop mechanics live in the node-runtime module.

The request pipeline exposes ordered hooks: onRequest, preParsing, preValidation, preHandler, preSerialization, onSend, onResponse, plus onError, onTimeout, and onRequestAbort. Validation sits between preValidation and preHandler; a schema failure short-circuits to a 400 without running your handler. Hooks obey the same encapsulation rules as decorators, so a preHandler auth hook registered in a plugin guards only that plugin's routes.

NestJS request lifecycle: guards, interceptors, pipes

This ordering is the classic Nest interview probe, because the components look interchangeable but fire at fixed points.

01middlewareglobals via app.use, then module order02guardsglobal, then controller, then route03interceptors (pre)wrap the handler, same order04pipesvalidate and transform handler args05route handlercontroller calls into the service06interceptors (post)unwind route to controller to global07exception filtersinnermost matching scope catches
fig · NestJS request lifecycle

Two consequences fall out of this order. A guard runs before pipes, so it sees the raw, un-coerced, un-validated body; authorization that needs a typed or validated value cannot rely on a ValidationPipe having run. And interceptors are the only component that straddles the handler: the pre-phase runs global to route, the post-phase unwinds route to global (they are RxJS operators wrapping an observable, so they resolve first-in-last-out). Exception filters do not sit on the happy path; a thrown exception at any stage jumps to the nearest matching filter, resolved route level first.

The singleton-default DI footgun

Nest providers are singletons by default: the container instantiates each one once, caches it, and injects the same instance everywhere. That is what makes a shared connection pool or config object trivial. The trap is request scope. Mark a provider Scope.REQUEST and it is created fresh per request, and scope bubbles up: any provider or controller that depends on a request-scoped provider becomes request-scoped too, all the way up the injection chain. A request-scoped service buried three layers down quietly forces a new instance of everything above it on every request, which shows up as latency under load.

Modules add a second boundary. A provider is private to the module that declares it unless the module lists it in exports; importing a module gives you only what it exported, not its whole provider set. @Global() makes a module's exports available everywhere without an explicit import, which is convenient and easy to overuse.

Wrong "Every provider is a singleton, so DI has no per-request cost." Right the default is singleton, but one request-scoped provider re-scopes its dependents, so audit what pulls in request scope before assuming a flat singleton graph.

Body limits, timeouts, and shutdown regardless of framework

Three protections do not care which framework you chose. A request-body cap is a denial-of-service guard: Express body parsers default to 100kb and answer an oversize body with a 413, Fastify takes a bodyLimit, and you should set the number explicitly rather than inherit a default. Timeouts bound how long a slow or stalled client can hold resources: the Node HTTP server's requestTimeout defaults to 300000ms, headersTimeout to 60000ms, and keepAliveTimeout to 5000ms in current Node, and a framework sits on top of those. Graceful shutdown (draining in-flight requests on SIGTERM) is owned by the node-server-patterns module; the point here is only that no framework choice exempts you from it.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

gapmap pro

You've read the Node web frameworks notes. An interviewer will ask you to prove them.

Know you're ready. Don't hope.

The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:

  • Mock interviews on your topics

    An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.

  • Voice test interviews

    The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.

  • A plan to your date

    Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.

  • A mentor inside every lesson

    Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.

Pro $20/mo · Pro+ $50/mo · every study note on the site stays free

builds on

more Node.js

was this useful?