nestjs
Express 5, Fastify 5, and the adapter I did not swap

Last November I wrote that NestJS earns its keep over plain
Express mostly by settling arguments, and
ended it waiting on two things: Express 5 arriving properly, and require()
of an ES module coming off its flag. Both happened this year, so this is the
follow-up — plus the question I got asked most in the meantime, which is
whether we should be on Fastify instead.
Short version of that answer, up front, because it should colour how you read the middle of this: I have not run Fastify in production. Not once. What I can tell you is what it claims, what the swap would cost inside NestJS, and why I decided against finding out on a billing service. What I cannot tell you is what its p99 looks like under our load, because I have not measured it.
The Nest 11 upgrade was an Express 5 upgrade
NestJS 11 shipped on 22 January.
It requires Node 20 or newer, and the change with the largest blast radius is
that @nestjs/platform-express now sits on Express 5 rather than Express 4.
Express 5 itself became the npm latest tag
on 31 March
with 5.1.0, about six months after it was published, so by spring this was
simply where the ecosystem was.
Most of the application did not notice, which is the dividend of having let the framework own the router. The parts that broke were the parts where we had reached past Nest and touched Express directly.
Wildcards had to be named. Express 5 moved to path-to-regexp 9, and a bare
* is no longer a valid path:
// Nest 10 / Express 4
@All('*')
notFound() {}
// Nest 11 / Express 5
@All('*splat')
notFound() {}
That caught our catch-all route, a proxy path, and — the one that took
longest to find — a useGlobalPrefix exclusion pattern. None of it is hard.
All of it is a runtime path-matching change rather than a compile error, so
the way you find it is by requesting the URL.
The deprecated response signatures are gone: res.send(status, body),
res.json(status, obj) and res.sendfile() no longer exist, and
res.redirect(url, status) has its arguments the other way round. In a Nest
codebase these only appear where someone used @Res() to take the raw
response object, which in our case was two file-download endpoints and a
legacy redirect.
The body parser changed its defaults. urlencoded now defaults extended to
false, and req.body is no longer initialised to {} for every request. If
you have a guard or interceptor that reads req.body.something on a GET,
that used to be undefined and is now a TypeError. We had one. It was in an
audit-logging interceptor, which is to say the least-exercised code path with
the widest reach — worth grepping for req.body before you upgrade rather
than after.
The logger change was the part operations noticed
Not a routing change at all, and the one I would have paid for on its own:
Nest 11's ConsoleLogger takes a json option.
const app = await NestFactory.create(AppModule, {
logger: new ConsoleLogger({
json: true,
colors: false,
}),
})
We ship container logs to OpenSearch. Before this, Nest's pretty human-readable line had to be reconstructed on the way in by a grok pattern that got the message right and the context wrong roughly whenever anyone logged an object. Now the framework emits structured records and the ingest pipeline stops guessing. Multi-line stack traces stop being several documents.
This is a small feature and it deleted a whole category of "the logs are lying to me" incidents. I keep the pretty logger locally and JSON everywhere else, selected on the same environment flag that picks the rest of the config.
Fastify, honestly, from the outside
Fastify 5 was released on 17 September
2024 and requires Node 20 or
newer. NestJS 11 supports it through @nestjs/platform-fastify, and switching
adapters is, on paper, one line:
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
)
What Fastify actually does differently, as I understand it from its docs and its maintainers rather than from operating it:
It is schema-first in both directions. You give a route a JSON Schema, and
Fastify uses it to validate the request and to compile a serialiser for the
response with fast-json-stringify. That second half is the interesting one —
serialising a known shape through generated code, instead of
JSON.stringify walking an unknown object, is where a meaningful part of the
claimed throughput advantage comes from. It is a real architectural difference,
not a micro-optimisation.
Its plugin system is encapsulated. Register a plugin inside a scope and its hooks and decorators apply to that scope, not globally. Express middleware is a flat list applied in registration order to whatever comes after it, which is simple to explain and easy to get subtly wrong in a large app.
And the swap is not one line. That is the part I want to be concrete about, because "just change the adapter" is the version of this advice that gets repeated:
- Express middleware does not apply.
@fastify/helmet,@fastify/cors,@fastify/multipartreplace their Express equivalents, with different options and different defaults. @Res()hands you a Fastifyreply, not an Expressres. Every place reaching for the raw object needs rewriting — for us, the two download endpoints above.- Anything in your dependency tree that assumes Express assumes wrong. Passport
strategies, some APM and instrumentation middlewares, and a fair number of
small libraries take
(req, res, next)and are done. - Static assets, view engines and file upload handling are configured through different packages.
- Nest's own validation pipe and DTOs still work, which means you keep
class-validatorand get none of thefast-json-stringifyserialisation benefit unless you also move to schemas — so the headline number and the drop-in change are not the same project.
None of that is an argument that Fastify is worse. It is an argument that the adapter swap is cheap to start and expensive to half-finish, and a billing service is a bad place to discover which parts you missed.
The benchmark that would change my mind is not the one on the website
Framework benchmarks measure a framework returning a small fixed payload with no work behind it. That is a legitimate measurement of the framework, and it is nearly useless as a prediction about a service.
On the endpoints I actually care about, a request spends its time in Mongo, in Stripe, in Redis, and in our own aggregation. The router is single-digit percentages of that budget. Halving the router's share is real and it is also not the thing standing between us and a better p99 — the query that does a collection scan when a tenant crosses some row count is.
So the honest position is: if I were starting a latency-critical service with few routes and a schema I was willing to write down twice, Fastify would be on the list and I would benchmark it against my own payloads, my own middleware and my own database, and look at p99 rather than mean. For an existing service where the router is not the bottleneck, changing it is a migration paid for by a number I have not measured. I would rather spend that week on the index.
Node 24 went LTS three weeks ago
Node 24 became Active LTS on 28 October as Krypton, with support running to April 2028. It ships V8 13.6, npm 11 and Undici 7. Four things in it have changed how we write code, and one has been a useful trap to understand.
require() of an ES module, unflagged. This is the one I was waiting for
last year. A Nest build is CommonJS; a growing number of small libraries ship
ESM only; the collision used to be ERR_REQUIRE_ESM and a choice between
pinning an old major, await import() in an async factory, or converting the
build. Now it just works:
const { greet } = require('./esm/index.js') // package.json has "type": "module"
With one hard boundary, which is worth knowing before you rely on it — the module graph has to be synchronous:
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph
with top-level await. Use import() instead.
So a library that awaits at the top level of its entry point is still off
limits from require, and --experimental-print-required-tla will tell you
which file in the graph did it.
AsyncLocalStorage now defaults to AsyncContextFrame. This one matters
more than it reads. Request-scoped context — tenant, correlation id, the
authenticated principal — is the thing people reach for Scope.REQUEST
providers to solve, and request-scoped providers make Nest rebuild part of the
injection graph per request. Putting the context in an AsyncLocalStorage
instead keeps every provider a singleton and lets any layer read the current
request's values without threading a parameter through five call sites. Node
24 rebuilt the propagation on AsyncContextFrame by default, which makes the
approach cheaper across await boundaries. It is the pattern I would now
default to for anything ambient.
URLPattern is global. No import. Handy for the webhook router that used
to own a small pile of regexes.
The permission model lost its --experimental- prefix. It is --permission
now, with --allow-fs-read, --allow-fs-write and friends. We run it on the
short-lived jobs — a reconciliation script has no business opening a socket —
and not yet on the API, where the allow-list would be large enough to be
theatre.
From V8 13.6 the ones we have actually used are Error.isError(), which
answers the cross-realm question instanceof Error gets wrong, and
RegExp.escape(), which deletes a hand-rolled escaping helper that every
codebase has and that is wrong in at least one repository per company.
node src/main.ts will never run a Nest app
Node has run TypeScript files directly since type stripping went on by default in 23.6 — and it was backported to 22.18 — and the obvious question is whether that removes the Nest build step. It does not, and the reason is structural rather than a missing feature.
Type stripping erases types. It does not transform syntax. NestJS is built on two pieces of syntax that cannot be erased. Decorators do not parse at all:
@Controller()
^
SyntaxError: Invalid or unexpected token
And parameter properties — the private readonly in every Nest constructor —
are rejected explicitly:
constructor(private readonly service: Service) {}
^^^^^^^^^^^^^^^^
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter
property is not supported in strip-only mode
Those are not oversights. Decorators are still a TC39 stage 3 proposal, Node will not ship a transform for a proposal that is not in the language, and the whole design goal of strip-only mode is that it removes characters without moving the remaining ones — so line numbers in a stack trace still point at your source. Parameter properties would require generating assignments.
And even if both were transformed, it would not be enough. Nest's container
resolves constructor dependencies from the type metadata that
emitDecoratorMetadata writes and reflect-metadata reads back. A stripper
by definition throws that away. Native TypeScript execution in Node is a
genuine convenience for scripts and small ESM services, and for a Nest
application the answer is still tsc or SWC, with experimentalDecorators
and emitDecoratorMetadata on.
Which is fine. I would rather the compile step were honest about being required than have it half-work.
Where this leaves the stack
NestJS 11 on Express 5, on Node 24, with a compile step, structured logs and
context in AsyncLocalStorage. The Express 5 migration took a couple of days
and the whole of it was in the places we had reached around the framework —
which is a reasonable argument for not doing that in the first place.
Fastify stays a thing I have read about. If I start something small and latency-sensitive I will try it properly and measure it, and if that happens I will write down actual numbers instead of somebody else's.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


