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
| Project | Version checked (2026-10-04) | Notes |
|---|---|---|
Node.js |
Stable (Stability 2) on Node.js 26, 24 and 22 |
The examples use |
Express 5 |
5.2.1 (npm |
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 |
Express 4 |
4.22.3 (npm tag |
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 |
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 |
End of life on 2025-06-30. Plugin majors must match the Fastify major; the examples below ran with |
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.
| Concern | Bare node:http |
With a framework |
|---|---|---|
Routing |
Compare |
Declarative routes with parameters, prefixes and method handling; automatic |
Request bodies |
Read the stream, enforce a size limit, check |
A parser with a default limit that answers |
Validation and serialisation |
Hand-written checks and |
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 |
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 |
|
A request logger ( |
Static files, templates, cookies, sessions, CORS, compression |
Written from scratch with streams and |
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).
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:
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();
});
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"}
Source: Express: Routing, Express: Writing middleware, Express: Using middleware, Express 5.x API: express()
The example shows the pieces of the Express request lifecycle, which are worth stating plainly because every Express surprise traces back to them.
| Kind | Where it is registered | Behaviour |
|---|---|---|
Application-level |
|
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 |
|
The same, scoped to the router’s prefix. Routers nest, which is how an Express application is split into modules. |
Built-in |
|
Shipped in the |
Third-party |
|
Installed from npm. They are ordinary middleware and follow the same rules. |
Error-handling |
|
Recognised by its four parameters (the arity matters, so the unused |
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:
| Option | Default | Why it matters |
|---|---|---|
|
|
The maximum body size; larger bodies fail with |
|
|
Accepts only objects and arrays at the top level, rejecting primitives such as a bare string. |
|
|
Requests with another content type are not parsed. |
|
|
Decompresses gzip, deflate (and, in Express 5, Brotli) request bodies; when it is |
|
none |
|
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.
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();
});
/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.
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();
});
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"}
| Express 4 | Express 5 | Behaviour to know |
|---|---|---|
|
|
A wildcard must be named. |
|
|
|
|
An array of paths, or validation in the handler |
Regular-expression characters are not supported in path strings. The characters |
|
|
|
|
|
Nested keys such as |
|
|
Destructuring |
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();
});
}
/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:
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();
});
});
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.
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:
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();
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-readablecode(FST_ERR_VALIDATION) and a message that names the part of the request (querystring/limit,params/id,body). Clients can switch oncode. -
Serialisation is a filter. The
responseschema keeps only the declared properties, sointernalNotenever 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 letsfast-json-stringifyskip genericJSON.stringifywork. -
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 integer2(query strings are always text), andadditionalProperties: falseremoves the unknownxandadminproperties instead of failing the request. If a stricter contract is wanted, supply a different Ajv through theajv.customOptionsserver option or a custom validator compiler, as the documentation shows. -
Content types are parsed by
Content-Type. Fastify has built-in parsers forapplication/jsonandtext/plain, so a plain-text body reaches validation as a string and fails with "must be object", while an unsupported type such asapplication/xmlyields415(FST_ERR_CTP_INVALID_MEDIA_TYPE, observed) unless a parser is added withaddContentTypeParser. The defaultbodyLimitis 1 MiB, answered withFST_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.
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();
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()(orlisten()) resolves once every plugin has loaded. A plugin that never finishes is reported after thepluginTimeout(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 withFST_ERR_PLUGIN_INVALID_ASYNC_HANDLER(shown in Async handlers, reply and plugin pitfalls). -
Prefer
fastify-pluginfor 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/jwtand 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).
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();
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
Source: Fastify: Hooks, Fastify: Lifecycle, Fastify: Errors
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.
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();
/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.
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));
"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"}
Source: Fastify: Logging, Pino: API
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.
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();
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.
| 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 |
Write |
The |
Use |
|
|
|
Swap the arguments. |
|
Use |
|
|
A plugin cannot mix the callback and promise styles |
Choose |
Semicolons in query strings are no longer separators ( |
Enable the option only if a client really sends |
Type providers split validator and serializer schemas |
Update |
Custom |
Reorder or set the option. |
Source: Fastify: V5 Migration Guide
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.
<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.
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();
});
200 text/html; charset=utf-8
<h1><b>hi</b></h1>
<ul>
<li><script>alert(1)</script> (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
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();
/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 enginesetting;@fastify/viewtakes the engine object (EJS, Handlebars, Pug, Nunjucks, Liquid and others). Express 5 makesres.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-Controlvalues above were observed) and, according to their documentation, answer conditional requests; the Fastify traversal request above returned404. Dotfiles are ignored by default in both, so.hiddenis not served. The Express migration guide notes that this now includes hidden directories in the requested path, so a.well-knowndirectory (used by ACME challenges and mobile app-link files) needs an explicit mount withdotfiles: 'allow', as in the example, which lets/.well-known/security.txtthrough while/assets/.hiddenremains a404. -
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
maxAgewith 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):
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');
});
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
| 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 |
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, |
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 |
You want a minimal async-first core and are happy to choose every component. |
hapi |
21.4.10 ( |
14.15 in |
Configuration-driven routes and a mature plugin system, with validation through the separate |
An organisation that prefers configuration over code and already uses the hapi module family. |
AdonisJS |
7.5.2 ( |
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 |
A small router built on the Web Standard |
Edge or serverless targets, or code shared between runtimes. On a pure Node.js server there is no extra benefit. |
NestJS |
12.1.2 ( |
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. |
Source: Express, Fastify, Koa, hapi, AdonisJS: Introduction, Hono, Hono: Node.js, NestJS
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/expressand@fastify/middieto 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()andbuildApp()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
| 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, |
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 |
|
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.bodyisundefined(not{}) when no parser ran. -
Separate
body-parser.express.json()andexpress.urlencoded()have been built in since Express 4.16; in Express 5urlencodeddefaults toextended: false. -
Fastify shorthand schemas and the old
listen. Fastify 5 requires complete JSON Schemas withtype, accepts onlylisten({ port }), and moved logger instances tologgerInstance. 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: callingreply.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
preHandleroronRequesthook 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.jsonbefore trusting its behaviour.
References
-
Express: Version Support, https://expressjs.com/en/support/
-
Express: Moving to Express 5, https://expressjs.com/en/guide/migrating-5/
-
Express: Routing, https://expressjs.com/en/guide/routing/
-
Express: Writing middleware, https://expressjs.com/en/guide/writing-middleware/
-
Express: Using middleware, https://expressjs.com/en/guide/using-middleware/
-
Express: Error handling, https://expressjs.com/en/guide/error-handling/
-
Express: Using template engines, https://expressjs.com/en/guide/using-template-engines/
-
Express: Serving static files, https://expressjs.com/en/starter/static-files/
-
Express: Hello world, https://expressjs.com/en/starter/hello-world/
-
Express 5.x API: express(), https://expressjs.com/en/5x/api/express/
-
Express: body-parser middleware, https://expressjs.com/en/resources/middleware/body-parser/
-
Express: serve-static middleware, https://expressjs.com/en/resources/middleware/serve-static/
-
Express: Production best practices, security, https://expressjs.com/en/advanced/best-practice-security/
-
Express: Production best practices, performance, https://expressjs.com/en/advanced/best-practice-performance/
-
Express: Health checks and graceful shutdown, https://expressjs.com/en/advanced/healthcheck-graceful-shutdown/
-
Fastify: Getting Started, https://fastify.dev/docs/latest/Guides/Getting-Started/
-
Fastify: V5 Migration Guide, https://fastify.dev/docs/latest/Guides/Migration-Guide-V5/
-
Fastify: Long Term Support, https://fastify.dev/docs/latest/Reference/LTS/
-
Fastify: Server, https://fastify.dev/docs/latest/Reference/Server/
-
Fastify: Routes, https://fastify.dev/docs/latest/Reference/Routes/
-
Fastify: Validation and Serialization, https://fastify.dev/docs/latest/Reference/Validation-and-Serialization/
-
Fastify: Hooks, https://fastify.dev/docs/latest/Reference/Hooks/
-
Fastify: Lifecycle, https://fastify.dev/docs/latest/Reference/Lifecycle/
-
Fastify: Plugins, https://fastify.dev/docs/latest/Reference/Plugins/
-
Fastify: Encapsulation, https://fastify.dev/docs/latest/Reference/Encapsulation/
-
Fastify: Decorators, https://fastify.dev/docs/latest/Reference/Decorators/
-
Fastify: Reply, https://fastify.dev/docs/latest/Reference/Reply/
-
Fastify: Errors, https://fastify.dev/docs/latest/Reference/Errors/
-
Fastify: Logging, https://fastify.dev/docs/latest/Reference/Logging/
-
Fastify: Testing, https://fastify.dev/docs/latest/Guides/Testing/
-
Fastify: Recommendations, https://fastify.dev/docs/latest/Guides/Recommendations/
-
Fastify: Ecosystem, https://fastify.dev/ecosystem/
-
fastify-plugin, https://github.com/fastify/fastify-plugin
-
@fastify/view, https://github.com/fastify/point-of-view
-
@fastify/static, https://github.com/fastify/fastify-static
-
@fastify/express, https://github.com/fastify/fastify-express
-
@fastify/middie, https://github.com/fastify/middie
-
light-my-request, https://github.com/fastify/light-my-request
-
Pino: API, https://getpino.io/#/docs/api
-
Koa, https://koajs.com/
-
hapi, https://hapi.dev/
-
AdonisJS: Introduction, https://docs.adonisjs.com/introduction
-
Hono, https://hono.dev/docs
-
Hono: Node.js, https://hono.dev/docs/getting-started/nodejs
-
NestJS, https://docs.nestjs.com/
-
Node.js API: HTTP, https://nodejs.org/docs/latest/api/http.html
-
Node.js API: Test runner, https://nodejs.org/docs/latest/api/test.html
-
Node.js API: Globals, AbortSignal, https://nodejs.org/docs/latest/api/globals.html#class-abortsignal
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/