Why I swap NestJS's Express adapter for Fastify on real services, what changes underneath, and the plugin, multipart, and Passport gotchas that actually cost time.
A Tuesday morning at the real-time trading platform I architected. We were chasing tail latency on a quiet REST surface fronting our WebSocket gateway. Health endpoints, auth refresh, account snapshot reads. p99 was around 180ms under load and I wanted it under 100. The hot path was already tight. The cold path was Express, dressed up as NestJS, doing more work per request than I’d realized.
I flipped one line of bootstrap to @nestjs/platform-fastify and reran the same load. p99 came down to roughly 95ms with no other change. Throughput on the same pod count went up by a little under a third. That was the day I stopped defaulting to Express.
NestJS isn’t tied to Express. The framework is an opinionated layer on top of a generic HTTP adapter, and @nestjs/platform-fastify is a drop-in adapter that swaps the underlying HTTP server. Your controllers, providers, modules, pipes, guards, interceptors, all that decorator surface is untouched. What changes is the request and response objects, the middleware system, and a couple of behaviors you’ve probably learned to lean on without noticing.
Two things to internalize before you flip the switch. Fastify’s middleware story is plugins, not Express middleware. NestJS papers over this for app.use(...) but the moment you reach for anything ecosystem-specific (cookies, helmet, rate limit, multipart) you want the Fastify plugin, not the Express one. And Fastify is schema-first. It’s happiest when you give it response schemas to validate against because that’s how it does fast JSON serialization. Ignore that and you leave perf on the table.
Here’s the smallest correct swap I ship in real services.
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
async function bootstrap() {
const adapter = new FastifyAdapter({
logger: false,
trustProxy: true,
bodyLimit: 10 * 1024 * 1024,
disableRequestLogging: true,
});
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
adapter,
{ bufferLogs: true },
);
await app.register(import('@fastify/helmet'), { global: true });
await app.register(import('@fastify/cookie'), {
secret: process.env.COOKIE_SECRET,
});
await app.register(import('@fastify/compress'));
await app.listen({ port: 3000, host: '0.0.0.0' });
}
bootstrap();
trustProxy: true is the one I see people miss when they’re behind an AWS ALB or Cloudflare. Without it, req.ip lies to you and your rate limiter rate-limits the load balancer.
The straight mapping I use day-to-day, with the gotchas I’ve actually hit on real services.
helmet to @fastify/helmet. cors to @fastify/cors. cookie-parser to @fastify/cookie. compression to @fastify/compress. express-rate-limit to @fastify/rate-limit, which is much faster, has built-in Redis support, and lets you key by anything without writing a custom store.
The one that catches people every time is multer. Multer is Express-only. On Fastify you use @fastify/multipart. There’s @nest-lab/fastify-multer if you need a Multer-compatible API for a large migration. For greenfield I reach for @fastify/multipart directly. It’s stream-first by default which is what you want.
A streaming multipart handler I use for uploads going to S3:
import { Controller, Post, Req } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { randomUUID } from 'crypto';
const s3 = new S3Client({ region: process.env.AWS_REGION });
@Controller('uploads')
export class UploadsController {
@Post()
async upload(@Req() req: FastifyRequest) {
const file = await req.file({
limits: { fileSize: 200 * 1024 * 1024 },
});
if (!file) throw new Error('no file');
const key = `uploads/${randomUUID()}/${file.filename}`;
const uploader = new Upload({
client: s3,
params: {
Bucket: process.env.UPLOADS_BUCKET,
Key: key,
Body: file.file,
ContentType: file.mimetype,
},
queueSize: 4,
partSize: 5 * 1024 * 1024,
});
await uploader.done();
return { key };
}
}
The file.file here is a Node Readable. We never buffer the upload, we pipe it straight into the S3 multipart uploader. On the Express+Multer path I used to write, the same upload would land in memory or in a tempfile first. On a service that handles real upload volume, that difference is everything.
The part of the migration that bit me the hardest, so I’ll be specific. @nestjs/passport works on Fastify. The session story does not work the way most Express-trained engineers expect.
If you use stateless JWT auth, you’re fine. passport-jwt plugs in, NestJS wires it the same way, no behavior change. This is what I run on most services. The strategy:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
interface JwtPayload {
sub: string;
tenantId: string;
exp: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: JwtPayload) {
if (!payload.sub || !payload.tenantId) {
throw new UnauthorizedException();
}
return { userId: payload.sub, tenantId: payload.tenantId };
}
}
Same code I’d write on the Express adapter. The bind point is the Passport strategy interface, not the HTTP server.
If you use server-side sessions, express-session is Express-only. The Fastify equivalent is @fastify/session plus @fastify/cookie, and the API surface is different enough that a literal one-to-one port doesn’t compile. Your Passport strategies still work because Passport’s strategy interface doesn’t care about the underlying server, but the req.session shape, the serializeUser plumbing, and any custom session store you wrote against the Express interface will need adapting.
The mistake I made the first time was reaching for @fastify/express to keep Express middleware working on Fastify so I could leave express-session in place. It works. It also re-introduces most of the overhead you were trying to escape. If you do this, measure first.
The Tuesday after a long bank holiday weekend at the trading platform. Market opens at 09:30 London. Pre-market ramp looked normal until 09:31 when the gateway tier started shedding connections en masse. Clients reconnected immediately, got dropped again, reconnected again. Every gateway pod pinned at 100% CPU within ninety seconds. p99 tick fan-out went from around 80ms to roughly 3 seconds and the charts started showing stale prices. I was on-call.
First instinct, scale the pods. kubectl scale from three to nine. The new pods hit the storm head-on and went CPU-bound within twenty seconds. I was feeding the fire. Worse, more pods meant more partial-success reconnects.
The real fix was two things in parallel. Client-side, an emergency remote-config push to jittered exponential backoff, min 200ms, max 30s, factor 2, jitter plus or minus 50%. Server-side, a per-IP connection-rate limiter at the nginx layer at three new connections per second per IP. Within about eight minutes the pool stabilized and fan-out came back under 200ms.
Roughly fourteen minutes of degraded delivery during one of the most-watched windows of the trading week. Lesson, autoscale is not a fix for a self-amplifying client-side bug. Backoff lives on the client. The reason this story belongs in a Fastify post, after the incident I rewrote the HTTP surface in front of the WebSocket gateway on @nestjs/platform-fastify because the request-per-second budget on the auth-and-handshake path was tight. The adapter swap bought us measurable headroom on the same hardware.
The combat-sports tournament platform I CTO’d in London. Hundreds of microservices, Kafka as the async backbone, a live federation broadcast on a Saturday afternoon. The HTTP edge wasn’t on Fastify yet. The lesson still reshaped how I deploy any service.
Around the third bout, the standings-projector consumer group started rebalancing every thirty seconds. Standings stopped reaching the public leaderboard. The page froze at 14:32 local. PagerDuty fired three times in two minutes and the federation’s tech contact pinged me directly.
First wrong fix, kubectl rollout restart deployment/standings-projector. Consumers re-joined cleanly. Then they triggered another rebalance forty seconds later. I was doing the same dance the group was already doing on its own.
Pulled pod logs side-by-side. One pod out of six had a different max.poll.interval.ms. Five had 300s. One had 60s. The sixth pod was running a stale container image. Someone had pushed a config-touching fix without bumping the image tag and the deployment had pulled :latest. That pod’s handler did a slow downstream call that occasionally took 70s, past its max.poll.interval.ms, so it kept getting kicked out of the group. Cordoned the bad pod, storm drained in ninety seconds. Proper patch over the weekend, SHA-pinned every Kafka-touching deployment.
Twelve minutes of stale standings during a live broadcast. Standing deploy rule from that day, pin image SHAs, never tags. The HTTP services we later moved onto Fastify inherited the same rule. Image discipline is upstream of HTTP adapter choice.
trustProxy: true and a bodyLimit in the adapter constructor. Both bite you in production if you don’t.@fastify/multipart and stream multipart bodies. Tempfiles and in-memory buffers are not the same thing as scalable.@fastify/session and real work.@fastify/express just to keep an Express middleware stack alive. It mostly defeats the point.Thanks for reading. If you’ve got thoughts, send them my way.