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()).

flowchart TB subgraph App["Your application code (platform independent)"] C[Controllers, providers] E[Guards, interceptors, pipes, filters] end Core["Nest core: router, lifecycle, exception zone"] AH["AbstractHttpAdapter (HttpAdapterHost.httpAdapter)"] EA["ExpressAdapter (@nestjs/platform-express)"] FA["FastifyAdapter (@nestjs/platform-fastify)"] EX["Express 5 + body-parser + cors"] FY["Fastify 5 + @fastify/middie + @fastify/cors + @fastify/formbody"] N["Node.js http / https server"] C --> Core E --> Core Core --> AH AH --> EA AH --> FA EA --> EX FA --> FY EX --> N FY --> N
Figure 1. The adapter sits between Nest and the HTTP library

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.

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

@nestjs/platform-express

@nestjs/platform-fastify (bundles fastify, @fastify/cors, @fastify/formbody, @fastify/middie)

Application type

NestExpressApplication

NestFastifyApplication

Default listening interface

all interfaces

localhost only; pass '0.0.0.0' as the second argument of listen()

Middleware (use())

Express Request, Response, NextFunction

raw Node.js IncomingMessage and ServerResponse through @fastify/middie

Native objects with @Req() / @Res()

Express Request / Response

FastifyRequest / FastifyReply

Redirect

res.redirect(302, url)

res.status(302).redirect(url)

Default enableCors() methods

GET,HEAD,PUT,PATCH,POST,DELETE

GET,HEAD,POST

Default body size limit

100kb (body-parser)

1 MiB (bodyLimit)

Query string parser

Express 5 default is the simple parser; app.set('query parser', 'extended') for nested objects

Fastify’s parser; the routerOptions.querystringParser option for nested objects

Wildcards

named wildcards in plain Express 5 ({splat}); Nest keeps a compatibility layer for

no wildcard in the middle of a route

Sub-domain routing (host in @Controller())

supported

not supported (no nested routers); use @RouteConstraints({ host }) for per-route host matching

Route conflicts options (shadow, 'specificity')

matter: Express is registration-order sensitive

no effect: find-my-way already ranks routes by specificity (duplicate is honoured on both)

Static files, views, compression, cookies, CSRF, multipart

useStaticAssets(), hbs, compression, cookie-parser, multer

@fastify/static, @fastify/view, @fastify/compress, @fastify/cookie, @fastify/csrf-protection, @fastify/multipart

Forcing keep-alive connections closed on shutdown

forceCloseConnections application option

forceCloseConnections option of the FastifyAdapter constructor

Testing without a socket

supertest against app.getHttpServer()

app.inject() (light-my-request) after getInstance().ready(), or supertest once initialised

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
main.ts (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:

books.controller.ts (platform independent)
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"}]

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

NestFactory.create(…, options)

httpsOptions: TLS key and certificate for the Express adapter (see HTTPS for the Fastify equivalent).

NestFactory.create(…, options)

cors: true or a CORS options object, equivalent to calling app.enableCors() (see CORS: different defaults on each platform).

NestFactory.create(…, options)

bodyParser: false disables the built-in global body parser; rawBody: true additionally keeps the raw Buffer of the body on req.rawBody (see Body size limits, raw body and extra content types).

NestFactory.create(…, options)

forceCloseConnections: implemented by the Express adapter only (see Keep-alive connections and shutdown).

NestFactory.create(…, options)

abortOnError: by default a failure while the application is created ends the process with exit code 1; set it to false to have the error thrown instead.

NestFactory.create(…, options)

routeConflictPolicy and routeResolutionStrategy (Nest 12, opt-in): diagnose and resolve shadowed routes on adapters where registration order matters.

new FastifyAdapter(options)

Fastify server options: logger, trustProxy, bodyLimit, https, forceCloseConnections, keepAliveTimeout, routerOptions (including querystringParser), rewriteUrl and the rest of Fastify’s server options; plus the Nest-specific multipart option that controls registration of @fastify/multipart.

The multipart option is covered on File Upload & Streaming. A complete bootstrap that combines the options this page discusses:

main.ts (Fastify, production-style)
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.

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

cors package, used by enableCors()

@fastify/cors, used by enableCors()

Security headers

helmet, or app.useSecurityHeaders() (12.1)

@fastify/helmet, or app.useSecurityHeaders() (12.1)

CSRF

csrf-csrf for tokens, or app.enableCsrfProtection() (12.1)

@fastify/csrf-protection, or app.enableCsrfProtection() (12.1)

Cookies

cookie-parser, or the built-in cookies (12.1)

@fastify/cookie, or the built-in cookies (12.1)

Sessions

express-session

@fastify/secure-session

Compression

compression

@fastify/compress

Static files

useStaticAssets(), @nestjs/serve-static

@fastify/static

Views

hbs and setViewEngine('hbs')

@fastify/view and handlebars

File upload

multer (FileInterceptor)

@fastify/multipart (MultipartModule, 12.1)

Body parsers

body-parser (json, urlencoded)

Fastify content-type parsers, @fastify/formbody

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.

fastify-only.controller.ts (redirect and headers)
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 };
  }
}
express-only.controller.ts (the same two handlers on Express)
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']:

logger.middleware.ts (Fastify)
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();
  }
}
app.module.ts
@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:

fastify-only.controller.ts (route config)
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 unnamed keeps 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 options routeConflictPolicy and routeResolutionStrategy: 'specificity' diagnose and fix that, but only matter on order-sensitive adapters: Fastify’s find-my-way router already ranks by specificity, so shadow is a no-op and 'specificity' sorting has no effect there, while the duplicate policy 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

{"filter[where][name]":"John","item[]":["1","2"]}

Express 5, app.set('query parser', 'extended')

{"filter":{"where":{"name":"John"}},"item":["1","2"]}

Fastify, default

{"filter[where][name]":"John","item[]":["1","2"]}

Fastify, routerOptions.querystringParser with qs

{"filter":{"where":{"name":"John"}},"item":["1","2"]}

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:

adapter-host.service.ts
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'));
  }
}

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();

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');
}

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:

adapter-agnostic.filter.ts
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.

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:

tls.ts
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"
main.ts (Express over HTTPS)
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);
main.ts (Fastify over HTTPS)
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).

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.

main.ts (HTTP and HTTPS together)
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:

shutdown-observer.ts
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 forceCloseConnections application option of NestFactory.create().

  • Fastify: the Nest application option is implemented by the Express adapter only; pass Fastify’s own forceCloseConnections server option to the FastifyAdapter constructor.

// 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.

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.

Checklist: moving an Express application to Fastify

  1. Install @nestjs/platform-fastify, create the application with new FastifyAdapter() and type it NestFastifyApplication.

  2. Pass '0.0.0.0' to listen() in containers.

  3. Replace every Express-only package with its Fastify counterpart from Platform-specific packages, or use the 12.1 built-ins.

  4. Search for @Req() and @Res() typed with express, rewrite them with fastify types, or return values instead. Redirects become res.status(302).redirect(url); exception filters should use response.send() or the adapter’s reply().

  5. Rewrite middleware that touches Express helpers; middleware now receives raw Node.js objects.

  6. Set methods in enableCors() explicitly.

  7. Check body limits: Fastify’s default is 1 MiB, and use the bodyLimit adapter option rather than a bare useBodyParser() call.

  8. Move httpsOptions to the adapter’s https option and forceCloseConnections to the adapter options; set trustProxy if you were using trust proxy.

  9. Replace app.set('query parser', 'extended') by routerOptions.querystringParser.

  10. Remove sub-domain routing (@Controller({ host })) or keep that part on Express.

  11. Run the e2e suite; use app.getHttpAdapter().getInstance().ready() before app.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 routerOptions and no longer offers request.routeConfig (use request.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,DELETE on Express against GET,HEAD,POST on Fastify), and the keep-alive page states that the Nest forceCloseConnections option 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.