Fastify & HTTP Platforms
|
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. |
NestJS does not implement an HTTP server. It sits on top of one, through a thin adapter, and two adapters ship with
the framework: Express (the default, @nestjs/platform-express) and Fastify (@nestjs/platform-fastify). This page is
written against NestJS 12.1 (npm 12.1.2), Express 5.2, Fastify 5.12, TypeScript 6 and Node.js 20.19+ or 22.12+, with
the versions and the behaviour described here checked on 2026-10-02; the examples assume a NestJS 12 project (ESM, Vitest,
Node.js 24). It explains what the two platforms share, where they differ in ways that
break code (CORS preflight, redirects, middleware, query parsing, body limits, HTTPS, shutdown), how to reach the native
server, and which Fastify options matter. For the controllers that run on either platform see
Controllers & Routing, for the abstraction itself see
Platform Agnosticism & Standalone Apps, and for
middleware in general see Middleware.
Two platforms behind one abstraction
Everything in Nest that is visible to your code (controllers, providers, guards, interceptors, pipes, exception
filters) is platform independent. A framework-specific adapter translates Nest’s route registration, request
and response handling, body parsing, CORS, static files and server lifecycle into calls on the underlying library.
Both adapters extend the abstract class AbstractHttpAdapter, and the adapter instance is registered as a globally
available provider, which is what makes HttpAdapterHost possible (see HttpAdapterHost and getInstance()).
The platform is chosen when the application is created. Express needs no setup because @nestjs/platform-express is
the default. Each platform has its own application interface, NestExpressApplication and NestFastifyApplication;
you only need to pass the type to NestFactory.create() when you want to call a platform-specific method such as
app.set() (Express) or app.register() (Fastify). Without the type argument those methods are not visible to
TypeScript.
Source: NestJS: First steps - Platform and NestJS: HTTP adapter
Express or Fastify
The official position is short. Express is the default because it is widely used, well known and has an enormous set of compatible middleware. Fastify is a high-performance, low-overhead framework, and the documentation describes it as significantly faster than Express, "nearly twice the benchmark results" in its words. Because Nest is framework independent you can migrate between the two, and Fastify is the better choice when raw speed is a priority. The documentation notes that an adapter can only be implemented for a library with a request/response pipeline similar to Express. The table summarises the differences that matter when you pick or switch; each row is detailed in a section below.
| Express adapter (default) | Fastify adapter | |
|---|---|---|
Package |
|
|
Application type |
|
|
Default listening interface |
all interfaces |
|
Middleware ( |
Express |
raw Node.js |
Native objects with |
Express |
|
Redirect |
|
|
Default |
|
|
Default body size limit |
|
1 MiB ( |
Query string parser |
Express 5 default is the simple parser; |
Fastify’s parser; the |
Wildcards |
named wildcards in plain Express 5 ( |
no wildcard in the middle of a route |
Sub-domain routing ( |
supported |
not supported (no nested routers); use |
Route conflicts options ( |
matter: Express is registration-order sensitive |
no effect: |
Static files, views, compression, cookies, CSRF, multipart |
|
|
Forcing keep-alive connections closed on shutdown |
|
|
Testing without a socket |
|
|
Installing and switching to Fastify
Install the platform package, then hand a FastifyAdapter to NestFactory.create(). The ESM template imports local
files with the .js extension; a CommonJS project drops the extension and nothing else changes.
npm i --save @nestjs/platform-fastify
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
By default Fastify listens only on the localhost interface. In a container or any host reached from outside, pass
'0.0.0.0' as the second argument of listen(); otherwise the process starts normally but nothing can connect to it.
This is the single most common reason a Fastify application "works locally and not in Docker".
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
Controllers, services, guards, pipes and filters need no change when they return values and do not touch the native request or response. A controller like the following runs unchanged on both adapters:
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
export interface Book {
id: string;
title: string;
}
@Controller('books')
export class BooksController {
private readonly books: Book[] = [{ id: '1', title: 'Dune' }];
@Get()
findAll(@Query('title') title?: string): Book[] {
return title ? this.books.filter((b) => b.title === title) : this.books;
}
@Get(':id')
findOne(@Param('id') id: string): Book | undefined {
return this.books.find((b) => b.id === id);
}
@Post()
create(@Body() body: { title: string }): Book {
const book = { id: String(this.books.length + 1), title: body.title };
this.books.push(book);
return book;
}
}
curl http://localhost:3000/books
[{"id":"1","title":"Dune"}]
Source: NestJS: Controllers
Code stops being portable at the moment it injects @Req() or @Res() and types them with express or fastify
types, or when a recipe relies on an Express-only package. The documentation’s own warning is that with the
FastifyAdapter "recipes that rely on Express may not work": use the Fastify equivalent, listed in Platform-specific packages.
Fastify options and application options
The FastifyAdapter constructor takes Fastify’s own server options. Everything Fastify documents for its factory
function can be passed here, for example the logger:
new FastifyAdapter({ logger: true });
The Nest application options passed as the last argument of NestFactory.create() are separate from the Fastify
options. The ones that interact with the HTTP platform are:
| Where | Option and effect |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
Fastify server options: |
The multipart option is covered on File Upload & Streaming.
A complete bootstrap that combines the options this page discusses:
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module.js';
import { loadTlsOptions } from './tls.js';
export async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: process.env.TRUST_PROXY === 'true', // behind a load balancer
bodyLimit: 5 * 1024 * 1024, // 5 MiB instead of Fastify's 1 MiB
forceCloseConnections: true,
...(process.env.TLS_KEY_PATH ? { https: loadTlsOptions() } : {}),
}),
{ rawBody: true },
);
app.enableShutdownHooks();
// Fastify listens on localhost only unless a host is given (needed in containers).
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
return app;
}
The tls.js helper is shown in HTTPS. Started with a generated test certificate, this bootstrap answers
requests over HTTPS and closes cleanly.
Source: NestJS: Performance (Fastify) - Fastify options, NestJS: HTTPS & multiple servers and NestJS: Raw body
Two Fastify 5 details are worth knowing. First, Fastify 5 moved the router options (ignoreTrailingSlash,
caseSensitive, querystringParser and similar) under routerOptions; the top-level form is deprecated: a top-level
querystringParser still works but prints Fastify’s deprecation warning
(FSTDEP022). The Nest documentation still shows the top-level form, so prefer routerOptions. Second, the Nest
adapter folds top-level router options into routerOptions for plugins such as @fastify/middie, so existing code
keeps working while you migrate.
Platform-specific packages
Whatever the documentation shows for Express has a Fastify counterpart. The recurring rule: Express middleware is
registered with app.use(); Fastify plugins are registered with app.register() (which needs the
NestFastifyApplication type).
| Concern | Express | Fastify |
|---|---|---|
CORS |
|
|
Security headers |
|
|
CSRF |
|
|
Cookies |
|
|
Sessions |
|
|
Compression |
|
|
Static files |
|
|
Views |
|
|
File upload |
|
|
Body parsers |
|
Fastify content-type parsers, |
The 12.1 built-ins (useSecurityHeaders(), enableCsrfProtection() and the cookie decorators) work the same way on
both platforms and need no extra package; the platform packages remain valid if you already use them. Each is covered
on its own page: Cookies & Sessions,
Security Headers, CORS & CSRF,
MVC, Static Files & Compression and
File Upload & Streaming.
As an example of registering a Fastify plugin directly, a plugin function can be passed to register(). This adds a
route on the Fastify instance that Nest does not know about:
import type { FastifyInstance } from 'fastify';
await app.register(async (instance: FastifyInstance) => {
instance.get('/raw-ping', async () => ({ pong: true }));
});
GET /raw-ping answers {"pong":true}. A route registered like this bypasses Nest entirely: Nest guards,
interceptors, pipes and exception filters do not run for it and it is absent from OpenAPI. Use plugins for
infrastructure (compression, helmet, CORS, static files) and controllers for application routes.
Native request and response, redirects
@Res() (alias @Response()) and @Req() expose the native objects. Injecting @Res() puts a handler in
library-specific mode: you are responsible for sending the response, and Nest interceptors that map the return value
no longer apply. @Res({ passthrough: true }) keeps Nest in charge of the body while you tweak headers or the status
code. Type the objects with the platform’s own types.
import { Controller, Get, Res } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
@Controller('fastify')
export class FastifyOnlyController {
@Get('old')
redirect(@Res() res: FastifyReply) {
res.status(302).redirect('/books'); // Fastify: set the status, then redirect(url)
}
// Passthrough keeps Nest in charge of the body while the reply is tweaked.
@Get('headers')
headers(@Res({ passthrough: true }) reply: FastifyReply) {
reply.header('x-served-by', 'fastify');
return { ok: true };
}
}
import { Controller, Get, Res } from '@nestjs/common';
import type { Response } from 'express';
@Controller('express')
export class ExpressOnlyController {
@Get('old')
redirect(@Res() res: Response) {
res.redirect(302, '/books'); // Express: redirect(status, url)
}
@Get('headers')
headers(@Res({ passthrough: true }) res: Response) {
res.set('x-served-by', 'express');
return { ok: true };
}
}
curl -i http://localhost:3000/fastify/old
HTTP/1.1 302 Found
location: /books
On Fastify the redirect responded 302 with location: /books, and /fastify/headers returned
{"ok":true} with x-served-by: fastify. Prefer the platform-neutral @Redirect() decorator and returned values
where you can, and keep native-object code in a small number of clearly named handlers, because every @Res() handler
is a place the controller is tied to one platform. The Fastify reply sends with reply.send(); exception filters that
call response.json() (an Express method) must use response.send() on Fastify, or deliver the response through the
HTTP adapter as shown in HttpAdapterHost and getInstance().
Middleware on each platform
Class and functional middleware bound with MiddlewareConsumer have the same consumer API on both platforms, and
middleware registered with app.use() is handled by the underlying platform, not by Nest’s middleware module. What
differs is the arguments of use(). On Fastify, Nest relies on @fastify/middie, which runs Connect-style functions
with the raw Node.js request and response, so type them with FastifyRequest['raw'] and FastifyReply['raw']:
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
export const seen: string[] = [];
// On Fastify (through @fastify/middie) middleware receives the raw Node.js objects.
@Injectable()
export class FastifyLoggerMiddleware implements NestMiddleware {
use(req: FastifyRequest['raw'] & { originalUrl?: string }, res: FastifyReply['raw'], next: () => void) {
seen.push(`${req.method} ${req.originalUrl ?? req.url} (${req.url})`);
res.setHeader('x-request-seen', 'true');
next();
}
}
@Module({ controllers: [BooksController] })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(FastifyLoggerMiddleware).forRoutes('books');
}
}
For GET /books the middleware recorded GET /books (/): req.originalUrl held the full path while req.url was /,
because middie strips the mount path from req.url. Do not base routing decisions on req.url in Fastify middleware,
and do not expect Express helpers (req.header(), res.json(), res.status()) on the raw objects; the response
header set with res.setHeader() reached the client. Because middleware sees raw objects, anything that needs the
parsed body or cookies belongs in a guard, interceptor or pipe, which see Nest’s ExecutionContext and work the same on
both platforms. See Middleware for the consumer API and ordering.
@RouteConfig() and @RouteConstraints()
Both decorators come from @nestjs/platform-fastify and expose Fastify route features that Express has no equivalent
for, so they only make sense in a Fastify-only application.
@RouteConfig() attaches a config object to the route (Fastify’s route config). Read it at runtime from the
request. In Fastify 5 the request exposes it as request.routeOptions.config; the sample in the Nest documentation
reads req.routeConfig, which Fastify 5 no longer provides, so that handler returns a 500 until
the read is changed to routeOptions.config. Declare the key you use through module augmentation so TypeScript
accepts it:
import { Controller, Get, Req } from '@nestjs/common';
import { RouteConfig } from '@nestjs/platform-fastify';
import type { FastifyRequest } from 'fastify';
// Teach TypeScript about the custom config key used by @RouteConfig().
declare module 'fastify' {
interface FastifyContextConfig {
output?: string;
}
}
@Controller('fastify')
export class FastifyOnlyController {
@RouteConfig({ output: 'hello world' })
@Get('config')
config(@Req() req: FastifyRequest) {
// Fastify 5: the route's config lives on request.routeOptions.config
return req.routeOptions.config.output;
}
}
GET /fastify/config returns hello world.
@RouteConstraints() applies Fastify’s route constraints, which let Fastify’s router choose between routes with the
same path based on request properties. The host constraint is the practical one: the same path can serve different
handlers per Host header. Declare the more specific route (with the constraint) and an unconstrained fallback:
import { Controller, Get } from '@nestjs/common';
import { RouteConstraints } from '@nestjs/platform-fastify';
@Controller('fastify')
export class FastifyOnlyController {
// Fastify's `host` constraint: this route exists only for requests to that Host header.
@RouteConstraints({ host: 'admin.bookshelf.test' })
@Get('admin')
adminOnly() {
return 'admin host';
}
@Get('admin')
publicAdmin() {
return 'any other host';
}
}
A request with Host: admin.bookshelf.test gets admin host; any other host gets any other host. The documentation
example constrains version: '1.2.x', but Nest replaces Fastify’s version constraint with its own implementation,
driven by enableVersioning(). A route constrained with version returns 404 for every
request, with Accept-Version: 1.2.0, Accept-Version: 1.x and no header alike, and the same happens with header
versioning enabled. Use Nest’s @Version() and enableVersioning() (see
Versioning & Global Prefix) for versions and reserve
@RouteConstraints() for other constraints such as host.
Routing differences
Nest registers routes in declaration order and both routers resolve the usual cases identically, but a few areas differ:
-
Wildcards. The
pattern works on both. Express 5 made wildcards stricter, so plain Express needs a named wildcard ({*splat}), but Nest provides a compatibility layer so an unnamedkeeps working. For an asterisk in the middle of a route, Express requires a named wildcard while Fastify does not support it at all. -
Sub-domain routing.
@Controller({ host })is not supported on Fastify because Fastify has no nested routers; use Express if you rely on it, or@RouteConstraints({ host })for a single route. -
Shadowed routes. On Express,
@Get(':id')declared before@Get('me')silently shadows it. The Nest 12 optionsrouteConflictPolicyandrouteResolutionStrategy: 'specificity'diagnose and fix that, but only matter on order-sensitive adapters: Fastify’sfind-my-wayrouter already ranks by specificity, soshadowis a no-op and'specificity'sorting has no effect there, while theduplicatepolicy applies on both. -
Query strings. Parsed values arrive as strings; the two platforms parse nested structures differently, as described next.
Query strings with brackets show the parser difference. For ?filter[where][name]=John&item[]=1&item[]=2, this is what
a controller received:
// Express: app.set('query parser', 'extended');
// Fastify: new FastifyAdapter({ routerOptions: { querystringParser: (str) => qs.parse(str) } })
@Get('search/echo')
echo(@Query() query: Record<string, unknown>) {
return { query };
}
| Setup | query received by the handler |
|---|---|
Express 5, default |
|
Express 5, |
|
Fastify, default |
|
Fastify, |
|
On Express 5 the default (simple) parser leaves bracket keys flat, which is why code that relied on nested query objects needs the
extended setting. qs is installed separately (npm i qs).
CORS: different defaults on each platform
app.enableCors() (or the cors application option) uses the Express cors package or @fastify/cors, depending on
the platform, and the two do not share defaults. With no options Express answers a preflight with
GET,HEAD,PUT,PATCH,POST,DELETE, while @fastify/cors answers only the CORS-safelisted methods GET,HEAD,POST. A
cross-origin PUT, PATCH or DELETE is therefore rejected at the preflight stage on Fastify unless you list the
method, while the same code works on Express. That is a classic regression when moving an application to Fastify.
Always set methods explicitly when the API is called from another origin:
app.enableCors({
origin: process.env.CORS_ORIGIN, // the front-end origin, from configuration
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
});
curl -i -X OPTIONS http://localhost:3000/books \
-H 'Origin: https://app.example' \
-H 'Access-Control-Request-Method: DELETE'
A preflight DELETE with default options is answered with access-control-allow-methods: GET,HEAD,POST
on Fastify and GET,HEAD,PUT,PATCH,POST,DELETE on Express; with the methods option above, Fastify answers
GET, HEAD, PUT, PATCH, POST, DELETE. The full CORS story (origins, credentials, security headers, CSRF) is on
Security Headers, CORS & CSRF and, for the browser side, on
CORS.
Source: NestJS: CORS
HttpAdapterHost and getInstance()
Sometimes code needs the underlying server, either from inside the application context or outside it. Every native server instance is wrapped in an adapter, and the adapter is registered as a globally available provider.
Outside the application context, call getHttpAdapter() on the application. Inside, inject HttpAdapterHost from
@nestjs/core. The host is not the adapter: read its httpAdapter property, which is an ExpressAdapter or a
FastifyAdapter (both extend AbstractHttpAdapter). The adapter’s getInstance() returns the native library instance
(the Express application, or the Fastify instance), and takes a type parameter so TypeScript knows which:
import { Injectable, OnModuleInit } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import type { FastifyInstance } from 'fastify';
@Injectable()
export class AdapterHostService implements OnModuleInit {
readonly events: string[] = [];
constructor(private readonly adapterHost: HttpAdapterHost) {}
onModuleInit() {
// httpAdapter is set by the time the module is initialised.
const { httpAdapter } = this.adapterHost;
this.events.push(`adapter=${httpAdapter.constructor.name}`);
// getInstance() returns the native library instance: here, a FastifyInstance.
// Hooks must be added before the instance is ready.
const fastify = httpAdapter.getInstance<FastifyInstance>();
fastify.addHook('onRequest', async (_req, reply) => {
reply.header('x-hook', 'onRequest');
});
this.adapterHost.listen$.subscribe(() => this.events.push('listening'));
}
}
Source: NestJS: HTTP adapter
On a Fastify application this records adapter=FastifyAdapter, and every response carries x-hook: onRequest.
Registering the same service on an Express application fails with fastify.addHook is not a function, which is the
cost of reaching for getInstance(): you are back to platform-specific code, so keep it in one provider and name it
after the platform.
Outside the application context:
const app = await NestFactory.create(AppModule);
const httpAdapter = app.getHttpAdapter();
const instance = httpAdapter.getInstance();
Source: NestJS: HTTP adapter
HttpAdapterHost also exposes listen$, a stream that emits when the server starts listening, and a boolean
listening property. After await app.listen(0, '0.0.0.0'), adapterHost.listening is true and
the listening event has been emitted.
this.httpAdapterHost.listen$.subscribe(() => console.log('HTTP server is listening'));
if (this.httpAdapterHost.listening) {
console.log('HTTP server is listening');
}
Source: NestJS: HTTP adapter
The adapter also offers platform-neutral methods, which is the right tool for code that must run on both platforms. The
documented example is an exception filter that delivers the response through the adapter instead of calling
response.json() or response.send() itself:
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
httpAdapter.reply(
ctx.getResponse(),
{
statusCode: status,
path: httpAdapter.getRequestUrl(ctx.getRequest()),
},
status,
);
}
}
Registered with app.useGlobalFilters(new AllExceptionsFilter(app.get(HttpAdapterHost))), a NotFoundException on
GET /failing produces 404 with {"statusCode":404,"path":"/failing"} on both the Express and the Fastify
application, from the same class. See
Exception Filters & Error Handling for filters.
Source: NestJS: HTTP adapter and NestJS: Exception filters
HTTPS
Set httpsOptions in the options object of NestFactory.create() for the Express adapter. The Fastify adapter takes
the same material through Fastify’s own https option in the FastifyAdapter constructor. Never keep the private key in
the repository or as a string in the code: load both files from paths that the deployment provides, and keep them out of
version control. This helper reads the paths from the environment and fails fast if they are missing:
import { readFileSync } from 'node:fs';
// The key and certificate are read from paths given by the environment, never from the repository.
export function loadTlsOptions() {
const keyPath = process.env.TLS_KEY_PATH;
const certPath = process.env.TLS_CERT_PATH;
if (!keyPath || !certPath) {
throw new Error('TLS_KEY_PATH and TLS_CERT_PATH must be set');
}
return { key: readFileSync(keyPath), cert: readFileSync(certPath) };
}
For local development and tests, generate a throwaway self-signed certificate outside the repository (for example in a
temporary directory, with openssl):
mkdir -p "$HOME/.local-certs"
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj '/CN=localhost' \
-keyout "$HOME/.local-certs/key.pem" -out "$HOME/.local-certs/cert.pem"
export TLS_KEY_PATH="$HOME/.local-certs/key.pem"
export TLS_CERT_PATH="$HOME/.local-certs/cert.pem"
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { loadTlsOptions } from './tls.js';
const app = await NestFactory.create(AppModule, {
httpsOptions: loadTlsOptions(),
});
await app.listen(process.env.PORT ?? 3000);
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ https: loadTlsOptions() }),
);
curl -k https://localhost:3000/books
Both applications answer 200 over TLS (the client has to skip certificate verification because the certificate is
self-signed, as curl -k does). A pitfall: passing httpsOptions as a Nest option together
with a FastifyAdapter does not enable TLS. The application starts and serves plain HTTP, and an HTTPS client gets a
protocol error (wrong version number). On Fastify always use the adapter’s https option. In production most teams
terminate TLS at a load balancer or reverse proxy and keep the Node.js process on plain HTTP; then configure trustProxy
(see Behind a proxy: trust proxy).
Source: NestJS: HTTPS & multiple servers
Multiple simultaneous servers
To listen on an HTTP port and an HTTPS port at once, create the Express server yourself, give it to the ExpressAdapter,
initialise the Nest application, and start the two Node.js servers from the same Express instance. This is the documented
recipe and it is Express-only; with Fastify run a second process or let a reverse proxy handle the second port.
import * as http from 'node:http';
import * as https from 'node:https';
import express from 'express';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import { AppModule } from './app.module.js';
import { ShutdownObserver } from './shutdown-observer.js';
import { loadTlsOptions } from './tls.js';
const server = express();
const app = await NestFactory.create(AppModule, new ExpressAdapter(server));
await app.init();
const httpServer = http.createServer(server).listen(3000);
const httpsServer = https.createServer(loadTlsOptions(), server).listen(3443);
Because you created the servers with http.createServer() and https.createServer(), Nest does not close them when
you call app.close() or when the process receives a termination signal. Close them yourself with a provider that
implements OnApplicationShutdown, and register the servers with it:
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import type * as http from 'node:http';
@Injectable()
export class ShutdownObserver implements OnApplicationShutdown {
private httpServers: http.Server[] = [];
public addHttpServer(server: http.Server): void {
this.httpServers.push(server);
}
public async onApplicationShutdown(): Promise<void> {
await Promise.all(
this.httpServers.map(
(server) =>
new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve(null);
}
});
}),
),
);
}
}
const shutdownObserver = app.get(ShutdownObserver);
shutdownObserver.addHttpServer(httpServer);
shutdownObserver.addHttpServer(httpsServer);
app.enableShutdownHooks();
Both servers (here on ephemeral ports) answer 200, and after app.close() neither is listening
any more. The shutdown hook mechanics are on
Lifecycle Events & Shutdown. The documentation warns that this
recipe does not work with GraphQL subscriptions.
Keep-alive connections and shutdown
By default the HTTP adapters wait for in-flight responses to finish before the application closes. A client that uses
Connection: Keep-Alive and a long-running request can therefore keep the process alive. The documented symptom is an
application that does not exit when you expect it to, usually with app.enableShutdownHooks() enabled and most often
under --watch in development. forceCloseConnections makes the application exit without waiting. It lives in two
different places:
-
Express: the
forceCloseConnectionsapplication option ofNestFactory.create(). -
Fastify: the Nest application option is implemented by the Express adapter only; pass Fastify’s own
forceCloseConnectionsserver option to theFastifyAdapterconstructor.
// Express
const expressApp = await NestFactory.create(AppModule, {
forceCloseConnections: true,
});
// Fastify
const fastifyApp = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ forceCloseConnections: true }),
);
To see the effect, take a GET /books/slow/wait handler that needs one second to answer, and call app.close() 200 ms
after sending the request. With forceCloseConnections: true the client request is aborted on both platforms
(on Express close() returns in well under a second); on Express with false, close() waits for the response to
complete, which then arrives normally. Treat the option as a development convenience or a hard shutdown deadline:
in production you generally want in-flight requests to finish, which is why most applications do not need it.
Combine a graceful default with a platform or orchestrator grace period instead of cutting responses off.
The other keep-alive knob is the idle timeout. Fastify accepts keepAliveTimeout (milliseconds) and passes it to
the Node.js server: setting 7000 gives server.keepAliveTimeout === 7000. When the Node.js
process sits behind a load balancer, keep this value higher than the balancer’s idle timeout, otherwise the process may
close a connection that the balancer is about to reuse and clients see sporadic resets. Nest 12 also makes the Express
adapter drain in-flight requests on graceful shutdown.
Body size limits, raw body and extra content types
Nest registers the json and urlencoded body parsers for you; disable that with bodyParser: false in the application
options. The default limits differ: Express (body-parser) accepts bodies up to 100kb, Fastify up to 1 MiB. A larger
body is rejected with 413, which is a frequent surprise on the first large payload. A JSON body
of about 2 MB gets 413 on Fastify ({"statusCode":413,"message":"Request body is too large"}) and a 200 kB body gets
413 on Express.
Express
useBodyParser() registers a body parser with options. For Express the first argument is the body-parser type:
import type { NestExpressApplication } from '@nestjs/platform-express';
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useBodyParser('json', { limit: '10mb' });
With this, the 200 kB body is accepted (201).
Fastify
The simplest, safest way to raise the limit on Fastify is Fastify’s own bodyLimit option, in bytes, on the adapter. It
applies to every route and keeps the default JSON and urlencoded parsers:
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ bodyLimit: 10_485_760 }), // 10 MiB
);
The 2 MB body is accepted (201) and echoed back whole. The documentation shows a second way, useBodyParser() with a
bodyLimit option:
const bodyLimit = 10_485_760; // 10MiB
app.useBodyParser('application/json', { bodyLimit });
Handle that call with care. useBodyParser() on Fastify marks the parsers as registered so that application start-up does
not override yours, which means the built-in JSON and urlencoded parsers are no longer installed. Called with only a type and options,
the content type is handled by a parser that returns the raw Buffer. After this exact call a JSON
POST /books with {"title":"Hi"} returns 201 but the handler’s body.title is undefined (the body is an
unparsed buffer), and an application/x-www-form-urlencoded request gets 415 Unsupported Media Type. If you use
useBodyParser() for a JSON limit, pass the parser function as the third argument:
app.useBodyParser(
'application/json',
{ bodyLimit: 10_485_760 },
(_req, body, done) => {
try {
done(null, JSON.parse(body.toString()));
} catch (error) {
done(error as Error);
}
},
);
With the parser function the 2 MB body is accepted and parsed. If you also need form bodies, register that content type
too. The same useBodyParser() is the way to accept other content types; on Fastify the type is a MIME type string, and
with no parser function the handler receives the raw Buffer:
app.useBodyParser('text/plain');
On Express, useBodyParser('text') uses body-parser’s text parser. Make sure the application is typed with
`NestExpressApplication or NestFastifyApplication; otherwise useBodyParser() is not found.
Raw body for signature checks
Webhook signature verification needs the unparsed bytes. Enable rawBody: true in the application options and read
req.rawBody through the RawBodyRequest convenience type; it only works while the built-in body parser is enabled
(do not pass bodyParser: false). On Fastify:
import { Controller, Post, type RawBodyRequest, Req } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
@Controller('hooks')
class HooksController {
@Post()
receive(@Req() req: RawBodyRequest<FastifyRequest>) {
const raw = req.rawBody; // a Buffer
return { bytes: raw?.length, body: req.body };
}
}
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
{ rawBody: true },
);
curl -X POST http://localhost:3000/hooks -H 'content-type: application/json' -d '{"a": 1}'
{"bytes":8,"body":{"a":1}}
The raw buffer had the 8 bytes sent (including the space) while req.body was the parsed object. On Express the same
code types the request with RawBodyRequest<Request> from express. The useBodyParser() method respects the rawBody
option: with rawBody: true and app.useBodyParser('text/plain') on Fastify, the handler read the text through
req.rawBody.toString() and req.body was a Buffer. See Webhooks and
File Upload & Streaming for uses.
Source: NestJS: Raw body
Behind a proxy: trust proxy
Behind a load balancer or reverse proxy the socket address is the proxy’s. To see the original client address from
X-Forwarded-For (for rate limiting, logs, geo rules), tell the adapter to trust the proxy. On Express use
app.set('trust proxy', …); on Fastify use the trustProxy server option:
// Express
const expressApp = await NestFactory.create<NestExpressApplication>(AppModule);
expressApp.set('trust proxy', 'loopback'); // trust the loopback address only
// Fastify
const fastifyApp = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ trustProxy: true }),
);
A request with X-Forwarded-For: 203.0.113.9 sees req.ip as 203.0.113.9 with trustProxy: true on Fastify and
with 'trust proxy' set to 'loopback' on Express (for a client on loopback), and as 127.0.0.1 on Fastify
without the option. Trust only the hops you control: trustProxy: true believes any client-supplied header, so on a
server that is directly reachable it lets callers spoof their address. Restrict it to the proxy addresses (both
platforms accept addresses or subnets) in production.
Testing on Fastify
supertest against app.getHttpServer() works with both platforms once the application is initialised. Fastify also
brings built-in injection of fake requests (light-my-request), exposed as app.inject(). The Fastify instance must be
ready first, otherwise the first request can fail because plugins and routes are still being registered:
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
let app: NestFastifyApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(
new FastifyAdapter(),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
it('GET /books/1', async () => {
const result = await app.inject({ method: 'GET', url: '/books/1' });
expect(result.statusCode).toEqual(200);
expect(result.json()).toEqual({ id: '1', title: 'Dune' });
});
afterAll(async () => {
await app.close();
});
Performance and benchmarks
The official documentation makes one performance claim: Fastify is significantly faster than Express, achieving nearly twice the benchmark results, while Express remains the default because of its popularity and ecosystem. It publishes no numbers for Nest itself on this subject, and this page does not either. Treat any figure that you read elsewhere as describing someone’s workload, hardware and Node.js version, not yours. What generally decides whether the platform matters for you:
-
Framework overhead is a small share of the time of a request that waits on a database or an HTTP call, so for I/O-bound handlers the platform choice rarely changes latency noticeably. It matters most for small, CPU-light handlers served at high request rates.
-
Nest’s own layers (guards, interceptors, pipes, serialization, validation) cost the same on both platforms; the difference is limited to the HTTP layer underneath.
-
A faster HTTP layer does not help if the process is blocked, if responses are not compressed or cached when they should be, or if the database is the bottleneck. See Performance Optimization for caching, serialization and profiling.
To decide with evidence, benchmark your own application: run the same build twice, once with ExpressAdapter and once
with FastifyAdapter (the platform-neutral code makes this a one-line change, as the controller above shows), against
the same data, on the same machine, with a realistic mix of routes and a load-testing tool of your choice, and compare
latency percentiles and throughput. Run it from a separate machine or at least a separate process so the generator does
not compete for the CPU.
Source: NestJS: Performance (Fastify)
Checklist: moving an Express application to Fastify
-
Install
@nestjs/platform-fastify, create the application withnew FastifyAdapter()and type itNestFastifyApplication. -
Pass
'0.0.0.0'tolisten()in containers. -
Replace every Express-only package with its Fastify counterpart from Platform-specific packages, or use the 12.1 built-ins.
-
Search for
@Req()and@Res()typed withexpress, rewrite them withfastifytypes, or return values instead. Redirects becomeres.status(302).redirect(url); exception filters should useresponse.send()or the adapter’sreply(). -
Rewrite middleware that touches Express helpers; middleware now receives raw Node.js objects.
-
Set
methodsinenableCors()explicitly. -
Check body limits: Fastify’s default is 1 MiB, and use the
bodyLimitadapter option rather than a bareuseBodyParser()call. -
Move
httpsOptionsto the adapter’shttpsoption andforceCloseConnectionsto the adapter options; settrustProxyif you were usingtrust proxy. -
Replace
app.set('query parser', 'extended')byrouterOptions.querystringParser. -
Remove sub-domain routing (
@Controller({ host })) or keep that part on Express. -
Run the e2e suite; use
app.getHttpAdapter().getInstance().ready()beforeapp.inject().
What changed recently, and where the book is outdated
-
Express 5 is the default (since Nest 11) and Fastify 5 is supported (current in Nest 12). Express 5 made wildcards and query parsing stricter; Fastify 5 moved router options under
routerOptionsand no longer offersrequest.routeConfig(userequest.routeOptions.config), which the Nest documentation sample still reads. -
Nest 12.1 (2026-09-23) added adapter-neutral features that work on both platforms without a platform package: cookies (
@Cookies(),setCookie()),app.useSecurityHeaders(),app.enableCsrfProtection()and Fastify upload interceptors backed by@fastify/multipart(MultipartModule). -
Nest 12 reworked HTTP adapter error mapping across the core, Express and Fastify adapters, made the Express adapter drain in-flight requests on shutdown, and added opt-in route-conflict options, which are meaningful only on Express.
-
The docs now state the CORS default-method difference explicitly (
GET,HEAD,PUT,PATCH,POST,DELETEon Express againstGET,HEAD,POSTon Fastify), and the keep-alive page states that the NestforceCloseConnectionsoption belongs to the Express adapter while Fastify takes its own option. -
The book (published January 2025) is an Express-centred book and does not cover the Fastify adapter, the
HttpAdapterHost, HTTPS, multiple servers, keep-alive handling, body limits or raw body; this page and the official documentation supply them.