Web Frameworks: Express & Fastify

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation and the npm registry on 2026-10-04, and every example below was run on Node.js 26, 24 and 22 with identical output unless the text says otherwise. The frameworks covered are Express 5 (5.2.1) and Fastify 5 (5.12.5), the two libraries that most Node.js HTTP services are built on directly, and which also sit under NestJS as its two HTTP platforms. Both are ordinary npm packages and not part of Node.js: they wrap the node:http module (Stability 2, Stable) described in HTTP Server Fundamentals and add routing, body parsing, error handling and a structure for growing an application. Express is the minimalist, middleware-chain framework that defined the style; Fastify is a schema-first framework built around encapsulated plugins and a very fast request path. Examples extend the Bookshelf scenario from Introduction & Architecture with a small books API; the same API is built in both frameworks so that the differences stand out. Every server in the examples listens on port 0 or is exercised in memory with inject(), so nothing collides with another process.

The stability and version map

Table 1. Versions and support facts covered on this page
Project Version checked (2026-10-04) Notes

Node.js node:http, fetch, node:test

Stable (Stability 2) on Node.js 26, 24 and 22

The examples use fetch as the HTTP client and node:test as the runner; both are built in.

Express 5

5.2.1 (npm latest)

5.0.0 was published on 2024-09-10, 5.1.0 on 2025-03-31 and 5.2.0 and 5.2.1 on 2025-12-01. Requires Node.js 18 or later (official support table). The npm package declares "engines": { "node": ">=18" }.

Express 4

4.22.3 (npm tag latest-4)

The Express support table lists the v4 line as ongoing and states that only the latest version of each major line is supported.

Fastify 5

5.12.5 (npm latest)

5.0.0 was published on 2024-09-17. It requires Node.js 20 or later, according to the v5 migration guide. The LTS table in the Fastify documentation lists Node.js 20 and 22; the examples here also passed on Node.js 24 and 26, which is observed behaviour and not a published support statement.

Fastify 4

4.29.1 (npm tag four)

End of life on 2025-06-30. Plugin majors must match the Fastify major; the examples below ran with @fastify/static 10.1.5 and @fastify/view 12.0.0, the versions that resolved on 2026-10-04.

Pino

10.4.0

The logger behind Fastify. It is installed as a Fastify dependency, so nothing extra is needed to use it.

EJS

6.0.1

The template engine used in the examples. Old releases are vulnerable (see Where the books are outdated).

Why frameworks exist

A bare node:http server hands you an IncomingMessage and a ServerResponse and nothing else. That is a deliberate design: the module parses HTTP and moves bytes, and stays out of every policy decision. The cost is that every real service ends up re-implementing the same dozen concerns, and getting some of them subtly wrong. A framework packages answers to those concerns so that handlers can be small and the failure behaviour is uniform.

Table 2. What a framework adds to node:http
Concern Bare node:http With a framework

Routing

Compare req.method and req.url by hand, or write a matcher (see the URLPattern example in HTTP Server Fundamentals)

Declarative routes with parameters, prefixes and method handling; automatic 404 and (in most frameworks) 405/OPTIONS behaviour

Request bodies

Read the stream, enforce a size limit, check Content-Type, parse, handle malformed input

A parser with a default limit that answers 400 or 413 for you (express.json() defaults to 100kb; Fastify’s bodyLimit defaults to 1 MiB)

Validation and serialisation

Hand-written checks and JSON.stringify of whatever the handler returns, including fields it should not leak

Express leaves it to you; Fastify validates input and serialises output from JSON Schema

Errors

A thrown exception in a callback can crash the process; every handler needs its own try/catch

One error path per application: an error handler that turns any failure into a response

Cross-cutting behaviour

Copy and paste into each handler

Middleware (Express) or hooks (Fastify) that run in a defined order for every request or a subset

Logging and request ids

console.log calls

A request logger (morgan for Express, Pino built in for Fastify) and a per-request id

Static files, templates, cookies, sessions, CORS, compression

Written from scratch with streams and fs

Maintained plugins and middleware

A framework also fixes a vocabulary. When a team says "middleware", "plugin" or "hook", everyone knows where the code runs and in which order, which matters more than any benchmark when an application is maintained for years. The trade-off is a dependency that has its own release cadence, its own major versions and its own security advisories, and that is why this page records exactly which versions were tested. A framework is not magic, though: it is still one 'request' listener on the main thread, so everything in Don’t Block the Event Loop applies unchanged, and the framework’s own cost per request is small next to a blocking call or a slow database query.

Frameworks sit at different points on the spectrum from "toolkit" to "platform". Express and Koa give you a small core and expect you to assemble the rest; Fastify and hapi add a plugin system with rules; AdonisJS and NestJS prescribe an application structure with built-in conventions. The Comparing the frameworks section places seven of them side by side, after the two main subjects have been covered in detail.

Express 5

Express is the oldest and most widely deployed Node.js web framework. Its model is a stack of functions: each middleware receives (req, res, next), can read or change the request and response, and either ends the response or calls next() to pass control on. A route is just a middleware bound to a method and a path. Express 5 keeps that model and the familiar API, removes a decade of deprecated methods, changes how paths are matched, and forwards rejected promises to the error handler. It is installed with npm install express@5, and ES modules and CommonJS both work (the examples use ESM, "type": "module" in package.json).

The Bookshelf books API in Express 5
import express from 'express';

const books = new Map([
  ['1', { id: '1', title: 'Efficient Node.js', year: 2025 }],
  ['2', { id: '2', title: 'Node.js Projects', year: 2025 }],
]);

export function createApp() {
  const app = express();
  app.disable('x-powered-by');

  // Application-level middleware: runs for every request, in registration order
  app.use((req, res, next) => {
    const started = process.hrtime.bigint();
    res.on('finish', () => {
      const ms = Number(process.hrtime.bigint() - started) / 1e6;
      console.log(`${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms.toFixed(1)} ms)`);
    });
    next();
  });
  app.use(express.json({ limit: '10kb' }));

  const router = express.Router();
  router.get('/', (req, res) => res.json([...books.values()]));
  router.get('/:id', (req, res, next) => {
    const book = books.get(req.params.id);
    if (!book) return next(); // falls through to the 404 handler
    res.json(book);
  });
  router.post('/', (req, res) => {
    const { title, year } = req.body ?? {};
    if (typeof title !== 'string' || title === '') {
      return res.status(400).json({ error: 'title is required' });
    }
    const book = { id: String(books.size + 1), title, year };
    books.set(book.id, book);
    res.status(201).location(`/books/${book.id}`).json(book);
  });
  app.use('/books', router);

  // Fall-through: no route matched
  app.use((req, res) => res.status(404).json({ error: 'not found' }));

  // Error-handling middleware: four parameters
  app.use((err, req, res, next) => {
    if (res.headersSent) return next(err);
    res.status(err.status ?? 500).json({ error: err.expose ? err.message : 'internal error' });
  });
  return app;
}

The module exports a factory instead of calling listen(), which is what makes it testable (see Testing a framework application). A client drives it through a server bound to port 0:

Exercising the app with fetch
import { createApp } from './express-app.js';

const server = createApp().listen(0, '127.0.0.1', async (error) => {
  if (error) throw error;
  const base = `http://127.0.0.1:${server.address().port}`;
  const show = async (path, init) => {
    const res = await fetch(base + path, init);
    console.log(res.status, res.headers.get('content-type'), await res.text());
  };
  await show('/books/1');
  await show('/books/99');
  await show('/books', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ title: 'Express in Action', year: 2026 }),
  });
  await show('/books', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"title":' });
  await show('/books', { method: 'POST', body: 'no content type' });
  await show('/books', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ title: 'x'.repeat(20000) }),
  });
  server.close();
});
Output (the request log lines come from the middleware, the others from the client)
GET /books/1 -> 200 (1.7 ms)
200 application/json; charset=utf-8 {"id":"1","title":"Efficient Node.js","year":2025}
GET /books/99 -> 404 (0.2 ms)
404 application/json; charset=utf-8 {"error":"not found"}
POST /books -> 201 (2.2 ms)
201 application/json; charset=utf-8 {"id":"3","title":"Express in Action","year":2026}
POST /books -> 400 (0.4 ms)
400 application/json; charset=utf-8 {"error":"Unexpected end of JSON input"}
POST /books -> 400 (0.1 ms)
400 application/json; charset=utf-8 {"error":"title is required"}
POST /books -> 413 (0.3 ms)
413 application/json; charset=utf-8 {"error":"request entity too large"}

The example shows the pieces of the Express request lifecycle, which are worth stating plainly because every Express surprise traces back to them.

Table 3. The five kinds of Express middleware
Kind Where it is registered Behaviour

Application-level

app.use(fn) or app.METHOD(path, fn)

Runs for every matching request, in the order of registration. The logging function above is placed first so that it sees every request, including those answered by later middleware.

Router-level

router.use(fn), router.METHOD(path, fn), mounted with app.use('/books', router)

The same, scoped to the router’s prefix. Routers nest, which is how an Express application is split into modules.

Built-in

express.json(), express.urlencoded(), express.text(), express.raw(), express.static(), express.Router()

Shipped in the express package. Since Express 4.16 they replace the separate body-parser and serve-static installs.

Third-party

app.use(morgan('combined')), cors(), helmet(), express-rate-limit, and so on

Installed from npm. They are ordinary middleware and follow the same rules.

Error-handling

app.use((err, req, res, next) ⇒ {}), registered after the routes

Recognised by its four parameters (the arity matters, so the unused next must stay). Runs when something calls next(err) or throws.

Calling next() moves to the next matching middleware; next('route') skips the remaining handlers of the current route; calling next(err) with anything else (the strings 'route' and 'router' have their own meaning) abandons the normal chain and jumps to the first error handler. A middleware that neither responds nor calls next() leaves the request hanging until a timeout, which is the most common way to break an Express app. If the response has already started and an error occurs (for instance while streaming), a custom handler must delegate to the default one with next(err), as the example does with res.headersSent; the default handler then closes the connection, because it can no longer change the status code. With NODE_ENV set to production the default handler hides stack traces, which is one reason to set it (see Production Deployment).

Parsing bodies with express.json()

express.json() is built on body-parser. It only looks at requests whose Content-Type matches its type option (application/json by default), accepts any Unicode encoding, inflates gzip and deflate bodies (Express 5 adds Brotli to the decoders, for express.json(), urlencoded(), text() and raw()), and puts the result in req.body. Its options matter for security:

Table 4. The express.json() options that matter
Option Default Why it matters

limit

"100kb"

The maximum body size; larger bodies fail with 413, as the last request in the example shows. Raise it per route, not globally, when one endpoint needs more.

strict

true

Accepts only objects and arrays at the top level, rejecting primitives such as a bare string.

type

"application/json"

Requests with another content type are not parsed. req.body is then undefined in Express 5 (it was {} in Express 4), which is why the handler above uses req.body ?? {}.

inflate

true

Decompresses gzip, deflate (and, in Express 5, Brotli) request bodies; when it is false, compressed bodies are rejected.

verify, reviver

none

verify receives the raw buffer, which is how webhook signatures are checked; reviver is passed to JSON.parse.

The official documentation warns that the shape of req.body is controlled by the client: req.body.title may be missing, a number, an array or an object whose toString is a string. Express does not validate it, so a handler (or a validation library such as Zod or Ajv, see Building a REST API) must. express.urlencoded() parses HTML form posts, and in Express 5 its extended option defaults to false, so nested keys like a[b]=c stay flat unless extended: true is set.

Async error handling

This is the headline behaviour change in Express 5. In Express 4 a handler that threw synchronously was caught, but an async handler that rejected produced an unhandled promise rejection, and with the default of Node.js 15 and later (--unhandled-rejections=throw) that is a process-level error. Express 4 applications therefore wrapped every handler, with a helper such as express-async-errors or a hand-written wrap(fn). In Express 5 the router inspects the return value: a promise that rejects is treated as next(error), so an async function can simply throw.

Rejected promises reach the error handler in Express 5
import express from 'express';
import { setTimeout as sleep } from 'node:timers/promises';

const app = express();

async function findBook(id) {
  await sleep(5);
  if (id === 'boom') throw new Error('database unavailable');
  if (id === 'missing') {
    throw Object.assign(new Error('no such book'), { status: 404, expose: true });
  }
  return { id, title: 'Efficient Node.js' };
}

// No try/catch and no next: a rejected promise is forwarded to the error handler (Express 5)
app.get('/books/:id', async (req, res) => {
  res.json(await findBook(req.params.id));
});

// A promise chain must be RETURNED, otherwise Express never sees the rejection
app.get('/chain/:id', (req, res) => findBook(req.params.id).then((book) => res.json(book)));

app.use((err, req, res, next) => {
  console.log('error handler got:', err.message);
  res.status(err.status ?? 500).json({ error: err.expose ? err.message : 'internal error' });
});

const server = app.listen(0, '127.0.0.1', async () => {
  const base = `http://127.0.0.1:${server.address().port}`;
  for (const path of ['/books/1', '/books/missing', '/books/boom', '/chain/boom']) {
    const res = await fetch(base + path);
    console.log(path, res.status, await res.text());
  }
  server.close();
});
Output
/books/1 200 {"id":"1","title":"Efficient Node.js"}
error handler got: no such book
/books/missing 404 {"error":"no such book"}
error handler got: database unavailable
/books/boom 500 {"error":"internal error"}
error handler got: database unavailable
/chain/boom 500 {"error":"internal error"}

The same handler on Express 4.22.3 (installed in a separate scratch project for comparison) never answered: the request timed out on the client and the process received an unhandledRejection, which was observed here and is the reason the old wrappers existed. Two rules remain in Express 5. First, the framework can only see a promise if the handler returns it: an async function always does, but a .then() chain started and not returned is invisible, and its rejection is unhandled, so it needs .catch(next). Second, the error handler must still decide what is safe to show: only errors flagged as client errors (here expose, the convention of the http-errors package that the body parser uses for its own errors) should reach the response body; everything else becomes a generic message, with the detail going to the log.

Path syntax changes

Express 5 upgraded its path matcher to path-to-regexp 8. The practical effect is that several Express 4 route strings no longer register: they throw while the route is being added, so an upgrade fails at startup instead of silently mis-routing. That is a reasonable failure mode, and the messages name the offending index.

Express 4 route strings that no longer work, and their Express 5 replacements
import express from 'express';

// Express 4 spellings now throw while the route is being registered
for (const path of ['/*', '/:file.:ext?', '/[discussion|page]/:slug', '/:id(\\d+)']) {
  try {
    express().get(path, () => {});
    console.log('accepted ', path);
  } catch (error) {
    console.log('rejected ', path, '->', error.message.split('\n')[0].slice(0, 70));
  }
}

const app = express();
app.get('/files/*splat', (req, res) => res.json(req.params)); // wildcard needs a name, not matching "/files"
app.get('/docs{/*rest}', (req, res) => res.json(req.params)); // braces make a segment optional
app.get('/image/:file{.:ext}', (req, res) => res.json(req.params)); // optional extension
app.get(['/discussion/:slug', '/page/:slug'], (req, res) => res.json(req.params)); // arrays replace [a|b]
app.get('/numbers/:n', (req, res) => {
  // regular expressions in the path are gone: validate in the handler
  if (!/^\d+$/.test(req.params.n)) return res.status(404).json({ error: 'not a number' });
  res.json({ n: Number(req.params.n) });
});
app.get('/query', (req, res) => res.json({ query: req.query, body: String(req.body) }));

const server = app.listen(0, '127.0.0.1', async () => {
  const base = `http://127.0.0.1:${server.address().port}`;
  for (const path of ['/files/a/b/c', '/files', '/docs', '/docs/x/y', '/image/logo', '/image/logo.png',
    '/page/intro', '/numbers/42', '/numbers/x', '/query?tag=a&tag=b&filter[name]=x']) {
    const res = await fetch(base + path);
    const text = await res.text();
    console.log(path.padEnd(38), res.status, text.startsWith('<') ? '(default HTML error page)' : text);
  }
  server.close();
});
Output
rejected  /* -> Missing parameter name at index 2: /*; visit https://git.new/pathToReg
rejected  /:file.:ext? -> Unexpected ? at index 11: /:file.:ext?; visit https://git.new/pathToRe
rejected  /[discussion|page]/:slug -> Unexpected [ at index 1: /[discussion|page]/:slug; visit https://git.new/pathToRegexp
rejected  /:id(\d+) -> Unexpected ( at index 4: /:id(\d+); visit https://git.new/pathToRegexp
/files/a/b/c                           200 {"splat":["a","b","c"]}
/files                                 404 (default HTML error page)
/docs                                  200 {}
/docs/x/y                              200 {"rest":["x","y"]}
/image/logo                            200 {"file":"logo"}
/image/logo.png                        200 {"file":"logo","ext":"png"}
/page/intro                            200 {"slug":"intro"}
/numbers/42                            200 {"n":42}
/numbers/x                             404 {"error":"not a number"}
/query?tag=a&tag=b&filter[name]=x      200 {"query":{"tag":["a","b"],"filter[name]":"x"},"body":"undefined"}
Table 5. Express 4 to Express 5 path and request changes
Express 4 Express 5 Behaviour to know

app.get('/*', fn)

app.get('/*splat', fn), or '/{*splat}' to include the root /

A wildcard must be named. req.params.splat is an array of path segments, not a string.

'/:file.:ext?'

'/:file{.:ext}'

? is gone; braces mark an optional part. An unmatched optional parameter is omitted from req.params (it used to be a key with the value undefined).

'/[discussion|page]/:slug' or '/:id(\\d+)'

An array of paths, or validation in the handler

Regular-expression characters are not supported in path strings. The characters ()[]?+! are reserved and must be escaped with a backslash to be matched literally. A route given as a real RegExp object still works.

req.params is a normal object

req.params has a null prototype for string paths

req.params.hasOwnProperty is no longer available; use Object.hasOwn(req.params, name). This hardens against prototype pollution.

req.query is writable, parsed by qs ("extended")

req.query is a getter, parsed with the "simple" parser

Nested keys such as filter[name] stay flat, as in the last request above. app.set('query parser', 'extended') restores nesting, as shown next. Assigning to req.query throws.

req.body defaults to {}

req.body is undefined until a parser runs

Destructuring req.body without a guard throws a TypeError for requests the parser skipped.

Opting back in to nested query strings
import express from 'express';
const app = express();
app.get('/simple', (req, res) => res.json(req.query));
const ext = express();
ext.set('query parser', 'extended');
ext.get('/extended', (req, res) => res.json(req.query));
for (const [a, p] of [[app, '/simple'], [ext, '/extended']]) {
  const s = a.listen(0, '127.0.0.1', async () => {
    const r = await fetch(`http://127.0.0.1:${s.address().port}${p}?filter[name]=x&filter[tag]=y`);
    console.log(p, await r.text()); s.close();
  });
}
Output
/simple {"filter[name]":"x","filter[tag]":"y"}
/extended {"filter":{"name":"x","tag":"y"}}

A few smaller Express 5 changes are easy to miss, and all of them are listed in the official migration guide: app.listen() passes errors such as EADDRINUSE to its callback instead of throwing, res.status() accepts only integers from 100 to 999, res.redirect(url, status) is now res.redirect(status, url), the signatures res.send(status), res.json(obj, status) and res.send(body, status) are gone, req.param(), app.del() and res.sendfile() are removed, express.static ignores dotfiles (including hidden directories in the request path) by default, and .js files are now served as text/javascript. The app.listen() change is easy to demonstrate:

A listen error now arrives in the callback
import express from 'express';
import net from 'node:net';

const blocker = net.createServer().listen(0, '127.0.0.1', () => {
  const { port } = blocker.address();
  // Express 5 passes listen errors (here EADDRINUSE) to the callback instead of throwing
  express().listen(port, '127.0.0.1', (error) => {
    console.log(error ? `listen failed: ${error.code}` : 'listening');
    blocker.close();
  });
});
Output
listen failed: EADDRINUSE

Express publishes codemods for the mechanical changes (npx codemod@latest @expressjs/v5-migration-recipe runs them all). Running a package through npx executes code from the registry, so review the diff it produces, as for any dependency change, and see npm & Package Management for the supply-chain precautions.

Fastify 5

Fastify starts from a different premise: a request is processed by a fixed lifecycle, and the application is a tree of plugins that attach routes, hooks and decorators to it. Routes can declare JSON Schemas for the parts of a request (body, querystring, params, headers) and for the response. Fastify compiles those schemas once, with Ajv for validation and fast-json-stringify for serialisation, which makes validation both a safety feature and the reason the framework is quick. Handlers may be async and simply return the response body. npm install fastify@5 is all that is needed; Pino and light-my-request (the engine behind inject()) come with it.

Routes, JSON Schema validation and serialisation

The same books API follows. Everything is registered inside a plugin, so the module can be mounted under any prefix and is independent of the server it runs on.

The Bookshelf books API in Fastify 5
import Fastify from 'fastify';

const bookSchema = {
  type: 'object',
  properties: {
    id: { type: 'string' },
    title: { type: 'string' },
    year: { type: 'integer' },
  },
  required: ['id', 'title'],
};

// A plugin is an async function (instance, options). Everything it registers is
// scoped to this plugin and its children: this is encapsulation.
async function bookRoutes(app, { store }) {
  app.get('/', {
    schema: {
      querystring: {
        type: 'object',
        properties: { limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 } },
        additionalProperties: false,
      },
      response: { 200: { type: 'array', items: bookSchema } },
    },
  }, async (request) => [...store.values()].slice(0, request.query.limit));

  app.get('/:id', {
    schema: {
      params: { type: 'object', properties: { id: { type: 'string', pattern: '^[0-9]+$' } }, required: ['id'] },
      response: { 200: bookSchema },
    },
  }, async (request, reply) => {
    const book = store.get(request.params.id);
    if (!book) return reply.code(404).send({ error: 'not found' });
    return book; // async handler: the returned value is the reply
  });

  app.post('/', {
    schema: {
      body: {
        type: 'object',
        properties: { title: { type: 'string', minLength: 1 }, year: { type: 'integer' } },
        required: ['title'],
        additionalProperties: false,
      },
      response: { 201: bookSchema },
    },
  }, async (request, reply) => {
    const book = { id: String(store.size + 1), ...request.body, internalNote: 'never serialised' };
    store.set(book.id, book);
    return reply.code(201).header('location', `/books/${book.id}`).send(book);
  });
}

export function buildApp(opts = {}) {
  const app = Fastify(opts);
  const store = new Map([
    ['1', { id: '1', title: 'Efficient Node.js', year: 2025, internalNote: 'x' }],
    ['2', { id: '2', title: 'Node.js Projects', year: 2025 }],
  ]);
  app.register(bookRoutes, { prefix: '/books', store });
  return app;
}

app.inject() runs a request through the complete lifecycle (routing, hooks, parsing, validation, handler, serialisation) without opening a socket, which is why no listen() call is needed:

Driving the app with inject()
import { buildApp } from './fastify-app.js';

const app = buildApp(); // logger is off by default
// No listen(): inject() runs the request through the whole lifecycle in memory
const show = async (opts) => {
  const res = await app.inject(opts);
  console.log(res.statusCode, res.headers['content-type'], res.body);
};
await show('/books/1'); // the response schema drops internalNote
await show('/books?limit=1');
await show('/books?limit=500');
await show('/books?limit=2&x=1'); // the query string "2" is coerced to 2; x is silently removed
await show('/books/abc');
await show('/books/99');
await show({ method: 'POST', url: '/books', payload: { title: 'Fastify in Action', year: 2026 } });
await show({ method: 'POST', url: '/books', payload: { title: 'Extra', admin: true } }); // admin is removed, not rejected
await show({ method: 'POST', url: '/books', payload: { year: 2026 } });
await show({ method: 'POST', url: '/books', payload: '{"title":', headers: { 'content-type': 'application/json' } });
await show({ method: 'POST', url: '/books', payload: 'plain', headers: { 'content-type': 'text/plain' } });
await app.close();
Output
200 application/json; charset=utf-8 {"id":"1","title":"Efficient Node.js","year":2025}
200 application/json; charset=utf-8 [{"id":"1","title":"Efficient Node.js","year":2025}]
400 application/json; charset=utf-8 {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"querystring/limit must be <= 50"}
200 application/json; charset=utf-8 [{"id":"1","title":"Efficient Node.js","year":2025},{"id":"2","title":"Node.js Projects","year":2025}]
400 application/json; charset=utf-8 {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"params/id must match pattern \"^[0-9]+$\""}
404 application/json; charset=utf-8 {"error":"not found"}
201 application/json; charset=utf-8 {"id":"3","title":"Fastify in Action","year":2026}
201 application/json; charset=utf-8 {"id":"4","title":"Extra"}
400 application/json; charset=utf-8 {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property 'title'"}
400 application/json; charset=utf-8 {"statusCode":400,"code":"FST_ERR_CTP_INVALID_JSON_BODY","error":"Bad Request","message":"Body is not valid JSON but content-type is set to 'application/json'"}
400 application/json; charset=utf-8 {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must be object"}

Several things in that output are worth reading closely, because they are where Fastify differs from what people expect.

  • Validation errors are generated, and their shape is stable. Every failure carries a statusCode, a machine-readable code (FST_ERR_VALIDATION) and a message that names the part of the request (querystring/limit, params/id, body). Clients can switch on code.

  • Serialisation is a filter. The response schema keeps only the declared properties, so internalNote never reaches the client even though the handler returned it. That is a safety net against leaking fields such as password hashes or internal flags, and it is also what lets fast-json-stringify skip generic JSON.stringify work.

  • Fastify’s Ajv is configured to coerce and clean. The documentation notes that Fastify uses a custom Ajv configuration, with coerceTypes: 'array' among other options. Two consequences were observed above: the query string value "2" is converted to the integer 2 (query strings are always text), and additionalProperties: false removes the unknown x and admin properties instead of failing the request. If a stricter contract is wanted, supply a different Ajv through the ajv.customOptions server option or a custom validator compiler, as the documentation shows.

  • Content types are parsed by Content-Type. Fastify has built-in parsers for application/json and text/plain, so a plain-text body reaches validation as a string and fails with "must be object", while an unsupported type such as application/xml yields 415 (FST_ERR_CTP_INVALID_MEDIA_TYPE, observed) unless a parser is added with addContentTypeParser. The default bodyLimit is 1 MiB, answered with FST_ERR_CTP_BODY_TOO_LARGE (413).

Plugins and encapsulation

Everything in Fastify is a plugin, including your own routes, and register() is how plugins are composed. A plugin receives an instance of Fastify that inherits from its parent, and anything it adds (decorators, hooks, parsers, schemas) is invisible to its parents and siblings. This is encapsulation, and it is what keeps a large application free of the global state that makes Express applications hard to refactor. When something should be shared (a database connection, a utility decorator), the plugin is wrapped with fastify-plugin, which opts out of encapsulation for that plugin only.

Encapsulated and shared plugins
import Fastify from 'fastify';
import fp from 'fastify-plugin';

const app = Fastify();

// Wrapped in fastify-plugin: decorators escape the plugin's scope (shared utility)
app.register(fp(async (instance) => {
  instance.decorate('clock', () => '2026-10-04T00:00:00Z');
}, { name: 'clock' }));

// Plain plugin: a private scope
app.register(async function admin(instance) {
  instance.decorate('adminOnly', 'visible in admin and its children only');
  instance.addHook('onRequest', async (request, reply) => {
    if (request.headers['x-role'] !== 'admin') return reply.code(403).send({ error: 'forbidden' });
  });
  instance.get('/stats', async function () {
    return { clock: this.clock(), adminOnly: this.adminOnly }; // `this` is the encapsulated instance
  });
}, { prefix: '/admin' });

app.register(async function pub(instance) {
  instance.get('/ping', async function () {
    return { clock: this.clock(), adminOnly: this.adminOnly ?? null };
  });
}, { prefix: '/public' });

await app.ready();
console.log('root sees clock:', typeof app.clock, '| root sees adminOnly:', typeof app.adminOnly);
for (const opts of [
  { url: '/admin/stats' },
  { url: '/admin/stats', headers: { 'x-role': 'admin' } },
  { url: '/public/ping' },
]) {
  const res = await app.inject(opts);
  console.log(opts.url, res.statusCode, res.body);
}
console.log(app.printRoutes());
await app.close();
Output
root sees clock: function | root sees adminOnly: undefined
/admin/stats 403 {"error":"forbidden"}
/admin/stats 200 {"clock":"2026-10-04T00:00:00Z","adminOnly":"visible in admin and its children only"}
/public/ping 200 {"clock":"2026-10-04T00:00:00Z","adminOnly":null}
└── /
    ├── admin/stats (GET, HEAD)
    └── public/ping (GET, HEAD)

The output shows the whole idea. The onRequest hook registered inside admin protects /admin/stats and does not touch /public/ping; the adminOnly decorator exists inside admin and is undefined everywhere else; the clock decorator, which came from a fastify-plugin-wrapped plugin, is visible to the root instance and to both scopes. Authentication built this way (a hook in a scoped plugin) is the Fastify equivalent of Express’s app.use('/admin', requireAdmin), and Authentication & Sessions builds on it. A few rules follow from the model.

  • Plugins load in registration order, and await app.ready() (or listen()) resolves once every plugin has loaded. A plugin that never finishes is reported after the pluginTimeout (10 seconds by default).

  • A plugin is either async (instance, opts) or callback-style (instance, opts, done), never a mix. Fastify 5 enforces this and fails with FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER (shown in Async handlers, reply and plugin pitfalls).

  • Prefer fastify-plugin for infrastructure that many scopes need, such as a database pool, and plain plugins for features. The official plugin guide recommends keeping each plugin small and focused.

  • The ecosystem is built from the same pieces: @fastify/static, @fastify/view, @fastify/cors, @fastify/helmet, @fastify/rate-limit, @fastify/cookie, @fastify/jwt and many more. Their major versions track Fastify’s, so when upgrading, upgrade them together.

Hooks and the request lifecycle

Hooks are Fastify’s counterpart of middleware, but they are attached at defined points of the lifecycle instead of one linear chain. The request-level hooks, in the order they run, are onRequest, preParsing, preValidation, preHandler, preSerialization, onSend and onResponse; onError, onTimeout and onRequestAbort fire for failures; and the application hooks (onReady, onClose, onRoute, onRegister) cover the server’s own lifecycle. Each hook can be added to the whole instance, a plugin scope or a single route, and an async hook finishes by returning (or throws to fail the request).

Hook order for success, validation failure, handler error and 404
import Fastify from 'fastify';

const app = Fastify();
const trace = [];
for (const name of ['onRequest', 'preParsing', 'preValidation', 'preHandler', 'preSerialization', 'onSend', 'onResponse']) {
  app.addHook(name, async () => { trace.push(name); });
}
app.addHook('onError', async (request, reply, error) => { trace.push(`onError(${error.message})`); });
app.addHook('onRequest', async (request) => { request.startedAt = process.hrtime.bigint(); });

app.post('/echo', {
  schema: { body: { type: 'object', properties: { n: { type: 'integer' } }, required: ['n'] } },
}, async (request) => ({ doubled: request.body.n * 2 }));
app.get('/fail', async () => { throw new Error('kaboom'); });

app.setErrorHandler((error, request, reply) => {
  // validation errors carry a statusCode (400); anything else is an internal error
  const status = error.statusCode ?? 500;
  reply.code(status).send({ error: status === 500 ? 'internal error' : error.message });
});
app.setNotFoundHandler((request, reply) => reply.code(404).send({ error: `no route for ${request.method} ${request.url}` }));

for (const opts of [
  { method: 'POST', url: '/echo', payload: { n: 21 } },
  { method: 'POST', url: '/echo', payload: { n: 'x' } },
  { method: 'GET', url: '/fail' },
  { method: 'GET', url: '/nope' },
]) {
  trace.length = 0;
  const res = await app.inject(opts);
  console.log(`${opts.method} ${opts.url} -> ${res.statusCode} ${res.body}`);
  console.log('  hooks:', trace.join(' > '));
}
await app.close();
Output
POST /echo -> 200 {"doubled":42}
  hooks: onRequest > preParsing > preValidation > preHandler > preSerialization > onSend > onResponse
POST /echo -> 400 {"error":"body/n must be integer"}
  hooks: onRequest > preParsing > preValidation > onError(body/n must be integer) > preSerialization > onSend > onResponse
GET /fail -> 500 {"error":"internal error"}
  hooks: onRequest > preParsing > preValidation > preHandler > onError(kaboom) > preSerialization > onSend > onResponse
GET /nope -> 404 {"error":"no route for GET /nope"}
  hooks: onRequest > preParsing > preValidation > preHandler > preSerialization > onSend > onResponse

The trace answers the questions people usually ask. A failed validation skips preHandler and the handler but still runs onError, serialisation and onSend/onResponse, so a logging or metrics hook in onResponse sees every request; the not-found handler runs the same hooks as a normal route (observed); and setErrorHandler is the single place that decides what clients see, which is where the generic internal error message for non-validation failures belongs. Validation errors carry statusCode: 400, so they are passed through; anything without a statusCode is treated as a server error. The not-found handler must set 404 itself, which the example does with reply.code(404). Use onRequest for work that is cheap and needs no body (authentication tokens, request ids), preHandler for work that needs the parsed and validated body (loading an entity and checking ownership), and onSend to change the payload or headers just before they are written.

Async handlers, reply and plugin pitfalls

The promise and reply styles of Fastify can be combined badly. The rule is that an async handler must either return the response or return reply.send(…​). If it returns undefined and sends later from a callback, Fastify has already finished the request.

The async handler pitfall and the Fastify 5 plugin rule
import Fastify from 'fastify';
import { setTimeout as sleep } from 'node:timers/promises';
import { Writable } from 'node:stream';

const logs = [];
const stream = new Writable({ write(c, e, d) { const l = JSON.parse(c); if (l.level >= 40) logs.push(`${l.msg}`); d(); } });
const app = Fastify({ logger: { level: 'warn', stream } });

// Wrong: async handler resolves with undefined while the reply is sent later
app.get('/bad', async (request, reply) => {
  setTimeout(() => reply.send({ late: true }), 5);
});
// Right 1: return the value
app.get('/ok-return', async () => ({ ok: true }));
// Right 2: send, and `return reply` so the promise is tied to the reply
app.get('/ok-reply', async (request, reply) => {
  await sleep(5);
  return reply.send({ ok: true });
});

for (const url of ['/ok-return', '/ok-reply', '/bad']) {
  const res = await app.inject(url);
  console.log(url, res.statusCode, res.body);
}
await sleep(20);
console.log('warnings:', logs);

// Fastify 5: a plugin may not mix async and a callback
try {
  const bad = Fastify();
  bad.register(async function mixed(instance, opts, done) { done(); });
  await bad.ready();
} catch (error) {
  console.log(error.code, '-', error.message);
}
await app.close();
Output
/ok-return 200 {"ok":true}
/ok-reply 200 {"ok":true}
/bad 200
warnings: [
  'Reply was already sent, did you forget to "return reply" in "/bad" (GET)?'
]
FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER - The mixed plugin being registered mixes async and callback styles. Async plugin should not mix async and callback style.

The /bad request received an empty 200 and the real body was dropped; the only trace is a warning in the log. Calling reply.send() twice, or both returning a value and calling send, are the same family of mistakes, and the correct form is always one send per request.

Logging with Pino

Fastify’s logger is Pino, which writes one JSON object per line to stdout and leaves formatting, shipping and rotation to other processes (see Diagnostic Reports, Logging & Observability). logger: true turns it on; an object configures it. Every request gets a child logger, request.log, that adds a request id (reqId) to each line, plus an "incoming request" and a "request completed" line with the response time.

Pino through Fastify: request ids, a child logger and redaction
import Fastify from 'fastify';
import { Writable } from 'node:stream';

// Capture Pino's newline-delimited JSON instead of printing it
const lines = [];
const stream = new Writable({ write(chunk, enc, done) { lines.push(JSON.parse(chunk)); done(); } });

const app = Fastify({
  logger: {
    level: 'info',
    stream,
    redact: ['headers.authorization'], // the path is relative to the logged object: never log credentials
    base: { service: 'bookshelf' },
    timestamp: false, // stable output for this demo; keep timestamps in production
  },
  requestIdHeader: 'x-request-id', // honour an id set by a trusted proxy
  genReqId: () => 'req-generated',
});

app.get('/books/:id', async (request) => {
  request.log.info({ bookId: request.params.id, headers: request.headers }, 'loading book'); // child logger carries reqId
  return { id: request.params.id };
});

await app.inject({ url: '/books/7', headers: { authorization: 'Bearer <token>', 'x-request-id': 'abc-123' } });
await app.inject({ url: '/books/8' });
await app.close();

for (const line of lines) console.log(JSON.stringify(line.responseTime ? { ...line, responseTime: '<varies>' } : line));
Output (the second request’s lines follow the same pattern with "reqId":"req-generated")
{"level":30,"service":"bookshelf","reqId":"abc-123","req":{"method":"GET","url":"/books/7","host":"localhost:80","remoteAddress":"127.0.0.1"},"msg":"incoming request"}
{"level":30,"service":"bookshelf","reqId":"abc-123","bookId":"7","headers":{"authorization":"[Redacted]","x-request-id":"abc-123","user-agent":"lightMyRequest","host":"localhost:80"},"msg":"loading book"}
{"level":30,"service":"bookshelf","reqId":"abc-123","res":{"statusCode":200},"responseTime":"<varies>","msg":"request completed"}

Notes on the output. The default request serializer logs the method, URL, host and remote address but not the headers, so secrets are not logged by default; the explicit headers field in the example is what made redact visible, and shows it replaces the value with [Redacted]. requestIdHeader makes Fastify trust an id set by an upstream proxy and falls back to genReqId when it is absent; trust it only when the proxy is yours. In Fastify 5 the logger option no longer accepts a ready-made logger object: pass a configuration object, or a Pino instance through the separate loggerInstance option. In development, pipe the output through pino-pretty (node server.js | npx pino-pretty) and keep JSON in production.

Request budgets and timeouts

Fastify 5.12.5 documents a handlerTimeout server option: the maximum time for a request to go through the whole route lifecycle, with a default of 0 (no timeout). When it expires Fastify answers with 503 (FST_ERR_HANDLER_TIMEOUT) and aborts request.signal, so cooperating code can stop work. It complements the socket-level connectionTimeout, requestTimeout and keepAliveTimeout options described in Server Timeouts, Limits & Hardening.

A handler budget with handlerTimeout and request.signal
import Fastify from 'fastify';
import { setTimeout as sleep } from 'node:timers/promises';

const app = Fastify({ handlerTimeout: 50 }); // whole-lifecycle budget in ms; 0 (the default) means none

app.get('/slow', async (request) => {
  try {
    await sleep(500, undefined, { signal: request.signal }); // request.signal aborts when the budget is spent
  } catch (error) {
    console.log('handler aborted:', error.name);
    throw error;
  }
});

const res = await app.inject('/slow');
console.log(res.statusCode, res.body);
await app.close();
Output
503 {"statusCode":503,"code":"FST_ERR_HANDLER_TIMEOUT","error":"Service Unavailable","message":"Request timed out after 50 ms on route '/slow'"}
handler aborted: AbortError

The option was present and working in 5.12.5 on all three Node.js lines; the documentation page does not state which release introduced it, so check the release notes before relying on it with an older Fastify 5. Express has no equivalent built-in budget; the usual answer there is the connect-timeout middleware or a reverse proxy timeout, and neither aborts the work already running. The cancellation pattern itself is covered in Async Context & Cancellation.

What Fastify 5 changed

Fastify 5 is a clean-up release: it removed everything deprecated in 4 and raised the Node.js floor. Code written for Fastify 3 or 4 (including most online tutorials) fails in these specific ways.

Table 6. Fastify 4 to Fastify 5 changes that break code
Change What to do

Node.js 20 or later is required

Run on Node.js 22, 24 or 26 (see the version table above).

Full JSON Schema is required for querystring, params, body and response; the jsonShortHand option is gone

Write { type: 'object', properties: { …​ } }; a bare { name: { type: 'string' } } is no longer a schema.

The logger option no longer accepts a logger instance

Use loggerInstance, or pass options to logger.

listen() has only the options-object form

fastify.listen({ port: 8000 }) (not listen(8000)); use port: 0 in tests.

reply.redirect(url, code) replaces redirect(code, url)

Swap the arguments.

request.connection, reply.getResponseTime(), request.context, request.routerPath and other deprecated properties are removed

Use request.socket, reply.elapsedTime and request.routeOptions.

req.params has no prototype

Object.hasOwn(req.params, 'name') instead of req.params.hasOwnProperty('name').

A plugin cannot mix the callback and promise styles

Choose async (instance, opts) or (instance, opts, done).

Semicolons in query strings are no longer separators (useSemicolonDelimiter is false)

Enable the option only if a client really sends a=1;b=2.

Type providers split validator and serializer schemas

Update @fastify/type-provider-typebox or @fastify/type-provider-json-schema-to-ts together with Fastify (see TypeScript in Node.js).

Custom HEAD routes must be registered before the GET route, or exposeHeadRoutes set to false

Reorder or set the option.

Templating and static files

A JSON API needs neither, but a Node.js server often also renders a few HTML pages (an admin screen, an email preview, a server-rendered landing page) and serves CSS and images. The two frameworks do this with a view-engine adapter and a static-file handler. Both examples below use the same files: a template views/books.ejs and a public/ directory containing site.css, a hidden file and a .well-known/ directory.

The EJS template used by both examples (views/books.ejs)
<h1><%= heading %></h1>
<ul>
<% for (const book of books) { %>
  <li><%= book.title %> (<%= book.year %>)</li>
<% } %>
</ul>

In EJS, <%= HTML-escapes the value and <%- writes it raw. Use the escaping form for everything that did not come from your own code; a raw tag on user input is a cross-site scripting hole. The examples feed a title containing a script tag and a query parameter containing markup, to show the escaping at work.

Express: view engine and express.static
import express from 'express';
import path from 'node:path';

const app = express();
app.set('views', path.join(import.meta.dirname, '..', 'views'));
app.set('view engine', 'ejs'); // Express require()s the "ejs" package and registers its renderFile

// Static files: mounted prefix, caching headers and the Express 5 dotfiles default
const publicDir = path.join(import.meta.dirname, '..', 'public');
app.use('/assets', express.static(publicDir, { maxAge: '1h', immutable: false }));
app.use('/.well-known', express.static(path.join(publicDir, '.well-known'), { dotfiles: 'allow' }));

app.get('/books', (req, res) => {
  res.render('books', {
    heading: req.query.q ?? 'All books',
    books: [{ title: '<script>alert(1)</script>', year: 2025 }, { title: 'Node.js Projects', year: 2025 }],
  });
});

const server = app.listen(0, '127.0.0.1', async () => {
  const base = `http://127.0.0.1:${server.address().port}`;
  let res = await fetch(`${base}/books?q=${encodeURIComponent('<b>hi</b>')}`);
  console.log(res.status, res.headers.get('content-type'));
  console.log(await res.text());
  for (const p of ['/assets/site.css', '/assets/.hidden', '/.well-known/security.txt']) {
    res = await fetch(base + p);
    console.log(p, res.status, res.headers.get('content-type'), res.headers.get('cache-control'));
  }
  server.close();
});
Output (blank lines left by the template tags are omitted)
200 text/html; charset=utf-8
<h1>&lt;b&gt;hi&lt;/b&gt;</h1>
<ul>
  <li>&lt;script&gt;alert(1)&lt;/script&gt; (2025)</li>
  <li>Node.js Projects (2025)</li>
</ul>
/assets/site.css 200 text/css; charset=utf-8 public, max-age=3600
/assets/.hidden 404 text/html; charset=utf-8 null
/.well-known/security.txt 200 text/plain; charset=utf-8 public, max-age=0
Fastify: @fastify/view and @fastify/static
import Fastify from 'fastify';
import fastifyStatic from '@fastify/static';
import fastifyView from '@fastify/view';
import ejs from 'ejs';
import path from 'node:path';

const root = path.join(import.meta.dirname, '..');
const app = Fastify();

await app.register(fastifyView, { engine: { ejs }, root: path.join(root, 'views') });
await app.register(fastifyStatic, {
  root: path.join(root, 'public'),
  prefix: '/assets/',
  dotfiles: 'ignore', // the default: .hidden answers 404
  maxAge: '1h',
});

app.get('/books', async (request, reply) => {
  return reply.view('books.ejs', {
    heading: request.query.q ?? 'All books',
    books: [{ title: '<script>alert(1)</script>', year: 2025 }, { title: 'Node.js Projects', year: 2025 }],
  });
});

for (const url of ['/books?q=%3Cb%3Ehi%3C%2Fb%3E', '/assets/site.css', '/assets/.hidden', '/assets/../package.json']) {
  const res = await app.inject(url);
  console.log(url, res.statusCode, res.headers['content-type'], res.headers['cache-control'] ?? '');
  if (url.startsWith('/books')) console.log(res.body);
}
await app.close();
Output (the rendered page is identical to the Express one and is omitted)
/books?q=%3Cb%3Ehi%3C%2Fb%3E 200 text/html; charset=utf-8
/assets/site.css 200 text/css; charset=utf-8 public, max-age=3600
/assets/.hidden 404 application/json; charset=utf-8
/assets/../package.json 404 application/json; charset=utf-8

Points that apply to both frameworks.

  • Template engines are interchangeable. Express discovers a engine by its file extension and view engine setting; @fastify/view takes the engine object (EJS, Handlebars, Pug, Nunjucks, Liquid and others). Express 5 makes res.render() asynchronous for every engine, which removes a class of bugs with engines that called back synchronously.

  • Static files are streamed, cached and path-checked. Both libraries stream files with a dedicated file sender, set caching headers (the Cache-Control values above were observed) and, according to their documentation, answer conditional requests; the Fastify traversal request above returned 404. Dotfiles are ignored by default in both, so .hidden is not served. The Express migration guide notes that this now includes hidden directories in the requested path, so a .well-known directory (used by ACME challenges and mobile app-link files) needs an explicit mount with dotfiles: 'allow', as in the example, which lets /.well-known/security.txt through while /assets/.hidden remains a 404.

  • Do not serve static assets from the application in production at scale. A reverse proxy or CDN does it more cheaply, and gives you compression and TLS (see Production Deployment). Use maxAge with fingerprinted file names so that long cache lifetimes are safe.

  • Server-side rendering stays simple on purpose. Templates are for small amounts of HTML. For a full React application with routing, data fetching and hydration, use a framework built for it: see Where to go next: NestJS and Next.js.

  • Keep the template engine current. EJS 3.1.6 and earlier are affected by CVE-2022-29078 (a server-side template injection when attacker-controlled option objects reach render). The examples installed EJS 6.0.1. Never pass request data as the engine’s options.

Testing a framework application

Both frameworks are designed so that the application object can be created without starting a server, and both examples above export a factory for that reason. The built-in node:test runner (Stability 2, Stable) and node:assert are enough (see The Node.js Test Runner):

One test file for both frameworks
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from './express-app.js';
import { buildApp } from './fastify-app.js';

test('Express: listen on port 0 and use fetch', async (t) => {
  const server = createApp().listen(0, '127.0.0.1');
  t.after(() => server.close()); // runs when the test finishes
  await new Promise((resolve) => server.once('listening', resolve));
  const base = `http://127.0.0.1:${server.address().port}`;

  const res = await fetch(`${base}/books/1`);
  assert.equal(res.status, 200);
  assert.equal((await res.json()).title, 'Efficient Node.js');

  const missing = await fetch(`${base}/books/99`);
  assert.equal(missing.status, 404);
});

test('Fastify: inject needs no socket at all', async (t) => {
  const app = buildApp();
  t.after(() => app.close());

  const res = await app.inject({ method: 'POST', url: '/books', payload: { title: 'Testing' } });
  assert.equal(res.statusCode, 201);
  assert.equal(res.json().title, 'Testing');
  assert.equal(res.json().internalNote, undefined); // the response schema removed it
  assert.equal(res.headers.location, '/books/3');

  const bad = await app.inject({ method: 'POST', url: '/books', payload: {} });
  assert.equal(bad.statusCode, 400);
  assert.equal(bad.json().code, 'FST_ERR_VALIDATION');
});
Output of node --test frameworks.test.js (abridged; the Express request log lines are omitted)
✔ Express: listen on port 0 and use fetch (18.9ms)
✔ Fastify: inject needs no socket at all (33.4ms)

The Express test needs a real socket (bound to port 0, so the operating system picks a free one) because Express has no in-memory request injector of its own; supertest, a widely used npm package, does the same with an ephemeral listener and a fluent API, and is a reasonable choice when a team prefers it. Fastify’s inject() is faster and avoids network flakiness, and is part of the framework. Both runs passed on Node.js 26, 24 and 22. Close servers in t.after() so that the process exits; a leaked listener is the usual reason a framework test run hangs.

Running either framework for real

The factory pattern leaves one small file that actually listens. Three habits matter there. Take the port and host from the environment (for example PORT loaded with node --env-file=.env, see CLI Options, Environment & Config File), and bind to 0.0.0.0 only inside a container: on a workstation, 127.0.0.1 keeps the service private. Handle SIGTERM by closing the server so in-flight requests finish (server.close() in Express; await app.close() in Fastify, which also runs onClose hooks and, with return503OnClosing, answers new requests with 503 while shutting down). And put a reverse proxy in front, telling the framework about it: Express needs app.set('trust proxy', …​) to read the client address from X-Forwarded-For, and Fastify needs the trustProxy option; set either narrowly (the hop count or the proxy’s address), never to true for a service that is reachable directly. The Express security guide’s checklist (TLS, treating user input as untrusted, Helmet, reduced fingerprinting, secure cookies, brute-force protection, dependency hygiene) and the performance guide’s (no synchronous APIs in handlers, NODE_ENV=production, a reverse proxy, clustering) apply, with different packages, to Fastify as well; see Security Best Practices. app.disable('x-powered-by'), used in the first example, removes the header that advertises the framework.

Comparing the frameworks

Table 7. Seven Node.js web frameworks (npm versions and downloads read on 2026-10-04; downloads are the npm count for 2026-09-25 to 2026-10-01 and are only indicative)
Framework Version (npm) and downloads per week Node.js floor (npm engines or docs) Design Choose it when

Express

5.2.1; about 166 million

18

Middleware chain, a built-in Router, no built-in validation or ORM. Express 5 handles rejected promises. The largest ecosystem and the most tutorials.

The team knows it, the service is small, or a library you depend on ships Express middleware.

Fastify

5.12.5; about 17 million

20 (migration guide)

Plugins with encapsulation, JSON Schema validation and serialisation, hooks, Pino logging, inject() testing, TypeScript type providers.

A new HTTP/JSON service where validated input, safe output and per-request cost matter, and the team wants a plugin architecture.

Koa

3.2.1; about 9 million

18

Written by the original Express team. A tiny core built on async functions with a single ctx object; no bundled middleware, so routing (@koa/router), body parsing and the rest are separate packages.

You want a minimal async-first core and are happy to choose every component.

hapi

21.4.10 (@hapi/hapi); about 1.7 million

14.15 in engines (the hapi status page lists 16 or later)

Configuration-driven routes and a mature plugin system, with validation through the separate joi module; its site stresses secure defaults and no middleware.

An organisation that prefers configuration over code and already uses the hapi module family.

AdonisJS

7.5.2 (@adonisjs/core); about 0.2 million

24

A backend-first, full-stack TypeScript framework that runs as ESM and ships authentication, file uploads, caching and rate limiting as first-party modules.

You want a Laravel- or Rails-like structure with conventions in TypeScript, on a recent Node.js LTS.

Hono

4.13.12; about 82 million

16.9 in engines (docs: 18.14.1 or later), through @hono/node-server

A small router built on the Web Standard Request and Response, so the same app runs on Node.js, Bun, Deno, Cloudflare Workers and other runtimes. It is the natural fit when code must move between runtimes.

Edge or serverless targets, or code shared between runtimes. On a pure Node.js server there is no extra benefit.

NestJS

12.1.2 (@nestjs/core); about 19 million

20

TypeScript decorators, modules and dependency injection on top of Express (the default) or Fastify. It is a platform, not a router: its structure is the product.

A large team and a long-lived codebase that need enforced structure; see Where to go next: NestJS and Next.js.

Download counts mostly reflect age and transitive use: Express is a dependency of many other packages, and Hono is pulled in by tooling for several runtimes, so a low number does not mean a poor framework, and a high one does not mean a good fit. Judge by maintenance activity, documentation, and the release dates at which each major version appeared: Koa 3.0.0 came out on 2025-04-28, Hono 4.0.0 on 2024-02-09, AdonisJS 7.0.0 on 2026-02-25 and @hapi/hapi 21.0.0 on 2022-11-07 (patch releases continue, as 21.4.10 shows). Fastify and Express are the two where the choice is real for a typical service, and the following rules of thumb hold up well.

  • Pick Fastify for a new service whose contract is JSON: schemas give validation, OpenAPI generation (through @fastify/swagger) and fast serialisation from one definition.

  • Pick Express to extend an existing application, to use a library that only ships Express middleware, or when team familiarity outweighs everything else. Express 5 removed the main reasons to avoid it (async errors) but not its lack of a built-in schema story.

  • Both can sit under NestJS later, and Fastify offers @fastify/express and @fastify/middie to mount Express-style middleware, which helps migrations that cannot happen in one step.

  • Whatever is chosen, keep route handlers thin and put business logic in plain modules that know nothing about the framework, as in the createApp() and buildApp() factories above. That separation is what makes a later change of framework a routing exercise instead of a rewrite.

Where to go next: NestJS and Next.js

The two frameworks above are deliberately unopinionated about application structure; at some size, a team wants the structure to be part of the framework. NestJS is that step on the server: it uses Express 5 or Fastify 5 as its HTTP platform (the choice is one adapter), and adds modules, dependency injection, pipes, guards and interceptors on top. Start with the NestJS section, and read Fastify & HTTP Platforms to see how the two platforms plug in, which makes this page’s examples directly relevant. Next.js is the React framework for the front end and for server-rendered pages: it brings its own router and its own server runtime, and can host route handlers that play the role an Express or Fastify service plays here. See the Next.js section and especially Route Handlers & Backend for Frontend for the part that overlaps with this page, and NestJS vs Next.js for how the two are combined. For a service that is just an HTTP API, the later page Building a REST API applies these frameworks to resource design, status codes and error formats, and WebSockets & Server-Sent Events shows how a framework server shares its http.Server with real-time endpoints.

What changed recently

Table 8. Framework facts that moved, with the version or date where known
When Change Effect on code

2024-09-10

Express 5.0.0 published on npm

Removed deprecated APIs, new path matching, promise-aware routing. Express 4 code needs the migration steps above.

2024-09-17

Fastify 5.0.0 published

Node.js 20 or later, full JSON Schemas, loggerInstance, options-only listen().

2025-06-30

Fastify 4 reached end of life

Plugin and security fixes now target Fastify 5 only.

2025-12-01

Express 5.2.0 and 5.2.1 published, the same day as 4.22.0 and 4.22.1

Both Express lines kept receiving releases (4.22.3 followed on 2026-09-14; 5.1.0 had been published on 2025-03-31).

2026-10-04

Observed on Fastify 5.12.5

handlerTimeout and request.signal are available, and the plugin family (@fastify/static 10, @fastify/view 12) tracks Fastify 5.

Node.js 22, 24, 26

Nothing in these frameworks depends on a Node.js release newer than 20

All examples gave identical output on the three lines; the choice of Node.js line is an operations decision (see Release Lines & LTS).

Where the books are outdated

Two O’Reilly books touch this page. Efficient Node.js by Samer Buna (2025) compares raw node:http routing with Express middleware, express.json() and morgan, and surveys Koa, hapi, AdonisJS, Next.js and NestJS; Node.js Projects by Jonathan Wexler (2025) builds its applications with Fastify (routes, plugins, prefixes, a not-found handler, hooks for authentication) and renders pages with EJS templates and static files. Both are good for the overall shape, and the details below are what a reader needs to correct while following them against the versions checked on 2026-10-04.

  • Express path strings. Examples written for Express 4 use * wildcards, :param? optional segments and path regular expressions. They throw at startup on Express 5 (see Path syntax changes) and must be rewritten with /*splat, braces and arrays.

  • Async wrappers. Helper code that wraps every Express handler to catch rejections is no longer needed on Express 5, and req.body is undefined (not {}) when no parser ran.

  • Separate body-parser. express.json() and express.urlencoded() have been built in since Express 4.16; in Express 5 urlencoded defaults to extended: false.

  • Fastify shorthand schemas and the old listen. Fastify 5 requires complete JSON Schemas with type, accepts only listen({ port }), and moved logger instances to loggerInstance. Code from Fastify 3 or 4 era books needs those three edits.

  • Not-found and double replies. A Fastify not-found handler must set the status itself (reply.code(404)), and a handler must send exactly one reply: calling reply.send() twice, or returning a value after sending, is a bug (see Async handlers, reply and plugin pitfalls).

  • Authentication as hooks. Putting a check in a preHandler or onRequest hook is the right Fastify pattern (see Plugins and encapsulation); the fixed JWT secrets, missing expiry and unpinned algorithms that sample code sometimes pairs with it are not, and are corrected in Authentication & Sessions. Read secrets from the environment and fail at startup when one is missing.

  • Template engines. Old EJS releases (3.1.6 and earlier) carry CVE-2022-29078. Use a current EJS and keep the escaping <%= tag for user data.

  • Versions. Neither book can show Node.js 26, Express 5.2, Fastify 5.12 or the Fastify 4 end of life; always check the version in an example’s package.json before trusting its behaviour.

References