Originally published on devanddeliver.com.
Introduction
Raw throughput benchmarks make NestJS look slow - but 17,000 requests per second is 1.5 billion requests a day, and the real question is whether your codebase can handle your team. We ran the benchmarks ourselves and made the case for NestJS as the professional default for Node.js backends in 2026.
Versions covered: Express 5.2.1 · Fastify 5.8.5 · NestJS 11.1.24
Benchmarks: Node.js 24.15, Apple Silicon, 100 connections, 20s
Last verified: May 2026
The short version
NestJS is the right default for professional backend work in Node.js in 2026. It is the only one of the three that gives you a complete application framework - dependency injection, module system, guards, interceptors, lifecycle hooks, and a testable architecture out of the box. Every serious team building a multi-module API in TypeScript will be better served by NestJS than by assembling the same things manually from Express middleware.
Fastify is the right choice when raw throughput is a first-class requirement - IoT ingestion, public APIs handling tens of thousands of requests per second per instance, or anything where cold start time matters (serverless, edge). It is an excellent HTTP server. It is not an application framework.
Express is the right choice when you are prototyping, maintaining an existing codebase, or need a thin layer with zero opinion. It is the foundation the Node.js ecosystem was built on. In 2026 it is still the right tool for small, focused services.
The mistake is comparing all three on throughput benchmarks and concluding NestJS is the slowest. NestJS trades a portion of raw req/s for something more valuable at scale: a consistent, testable, team-friendly application architecture. If your server is handling 20,000 requests per second, it is serving over 1.7 billion requests a day. The question is not whether NestJS can handle your load. It almost certainly can. The question is whether your codebase can handle your team.
First, understand what each one is
Express is a routing layer with middleware. That is almost all it does - deliberately. It gives you (req, res, next) and gets out of the way. Express v5 (the current stable) arrived in late 2024 after years in beta and fixed the most common footgun: unhandled promise rejections now propagate to your error middleware automatically. Everything else is largely the same as it has been for a decade. That is partly a strength: the Express mental model is universal, its middleware ecosystem is enormous, and there are no surprises.
Fastify optimises for throughput. Its two architectural choices - a compiled JSON serializer and AJV-based schema validation baked into the router - make it the fastest mainstream Node.js HTTP framework. It uses a plugin system with encapsulation rather than Express's flat middleware chain. The result is a framework that is opinionated about performance and relatively unopinionated about everything else: you still compose your own structure, your own testing patterns, your own cross-cutting concerns.
NestJS is an application framework that runs on top of Express or Fastify and solves the problems those frameworks leave to you. It brings Angular-style architecture to the backend: a dependency injection container, a module system, decorators for controllers and services, guards for auth, interceptors for cross-cutting logic, exception filters for consistent error handling, and a TestingModule API that makes unit testing injectable services trivial. NestJS does not compete with Express or Fastify on throughput. It uses them as its HTTP layer and adds everything a production application actually needs on top.
NestJS v11 defaults to the Express v5 adapter. It supports the Fastify adapter natively via @nestjs/platform-fastify.
Framework snapshots
| Express 5.2.1 | Fastify 5.8.5 | NestJS 11.1.24 | |
|---|---|---|---|
| Node.js minimum | 18+ | 20+ | 20+ |
| TypeScript | Optional | Optional | First-class |
| DI container | ❌ | ❌ | ✔️ |
| Module system | ❌ | ❌ | ✔️ |
| Built-in validation | ❌ | JSON Schema (AJV) | class-validator / Pipes |
| Auth pattern | Middleware | Plugin hook | Guard (composable, testable) |
| OpenAPI / Swagger | Manual / plugin | Manual / plugin | Auto-generated from DTOs |
| Testing utilities | None built-in | None built-in | TestingModule built-in |
| Microservices support | ❌ | ❌ | (gRPC, Kafka, RabbitMQ) |
| Weekly npm downloads | 107M¹ | 7.9M | 10.7M |
| GitHub stars | ~69k | ~33k | ~67k |
¹ Express downloads are heavily inflated by transitive dependencies - NestJS itself pulls Express under the hood. The intentional install numbers are closer to 10-15M per week.
What NestJS gives you that the others do not
Before the code comparison and benchmarks, it is worth being explicit about what you are actually getting with NestJS. These are not marginal conveniences - they are the reason teams choose it.
DEPENDENCY INJECTION AND TESTABILITY
In Express or Fastify, services depend on each other through imports, globals, or manual wiring. In NestJS, every service declares its dependencies and the DI container wires them at startup. The payoff is in testing:
// Test a service in complete isolation - no real database, no HTTP server const module = await Test.createTestingModule({ providers: [ UsersService, { provide: PrismaService, useValue: { user: { findUnique: jest.fn().mockResolvedValue(mockUser) } }, }, ], }).compile(); const usersService = module.get<UsersService>(UsersService); Three lines to swap the real Prisma client for a mock. This pattern works across your entire application - any service, any dependency, no special wiring required. Achieving equivalent test isolation in Express means passing dependencies through constructors manually everywhere or introducing a third-party DI library yourself.
A MODULE SYSTEM THAT SCALES WITH YOUR TEAM
NestJS organises code into modules with explicit import/export boundaries. UsersModule exports UsersService. AuthModule imports UsersModule to access it. If AuthModule does not import UsersModule, it cannot access UsersService - the DI container enforces the boundary at startup.
This matters less on a solo project. It matters a lot when four developers are working on the same codebase and "which service can I use from this module" has a clear, enforced answer instead of "just import the file and hope nothing breaks."
GLOBAL CROSS-CUTTING CONCERNS - WRITE ONCE, APPLY EVERYWHERE
// main.ts - configure once app.useGlobalPipes(new ValidationPipe({ whitelist: true })); app.useGlobalGuards(new JwtGuard()); app.useGlobalFilters(new AllExceptionsFilter()); app.useGlobalInterceptors(new LoggingInterceptor()); Every route in the application now validates input, enforces JWT auth, formats errors consistently, and logs requests - without touching a single route handler. In Express, the equivalent requires adding middleware to every router, trusting that someone remembered, and hoping the chain is in the right order.
Individual routes can opt out or add their own guards without disturbing the global defaults:
@Get('health') @Public() // custom decorator to skip JwtGuard on this route healthCheck() { return { status: 'ok' }; } OPENAPI DOCUMENTATION GENERATED FROM YOUR DTOS
Install @nestjs/swagger, add a few decorators to your DTOs, and NestJS generates full OpenAPI documentation automatically. No separate schema file, no manual sync, no risk of docs drifting from the actual API:
import { ApiProperty } from '@nestjs/swagger'; export class CreateUserDto { @ApiProperty({ example: 'Alice' }) @IsString() name: string; @ApiProperty({ example: 'alice@example.com' }) @IsEmail() email: string; } The same DTO that validates incoming requests also documents the API endpoint. One source of truth.
THE BROADER ECOSYSTEM
NestJS ships first-party packages for the things every real backend eventually needs: @nestjs/config (configuration with environment validation), @nestjs/schedule (cron jobs), @nestjs/bull (queues via BullMQ), @nestjs/event-emitter (in-process events), @nestjs/microservices (gRPC, Kafka, RabbitMQ, NATS with the same Guards/Interceptors pattern as HTTP). These are all maintained by the NestJS core team, follow the same conventions, and require no architectural rethinking to introduce into an existing app.
Mental model: how a request moves through each
Express:
request → middleware A → middleware B → route handler → response Everything is a function with (req, res, next). You own the chain.
Fastify:
request → onRequest hooks → JSON Schema validation → handler → serialization → response Validation runs at the framework level before your handler. Invalid bodies are rejected automatically.
NestJS:
request → middleware → guards → interceptors (before) → pipes (transform + validate) → controller → service → interceptors (after) → exception filters → response Each layer has one job. Guards decide whether a request is allowed through. Pipes transform and validate the body. Interceptors handle logging, caching, response shaping. Exception filters handle every possible error in one place. You configure each layer once globally, then override per-route only when you need to.
Code comparison: CRUD + validation
The same endpoint in each framework - create a user, list users, get by ID, delete by ID. TypeScript throughout.
EXPRESS V5 + ZOD
// app.ts import express, { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; const app = express(); app.use(express.json()); const users: { id: number; name: string; email: string }[] = []; let nextId = 1; const CreateUserSchema = z.object({ name: z.string().min(1), email: z.string().email(), }); app.get('/users', (_req: Request, res: Response) => { res.json(users); }); app.post('/users', (req: Request, res: Response) => { const result = CreateUserSchema.safeParse(req.body); if (!result.success) { return res.status(400).json({ errors: result.error.flatten() }); } const user = { id: nextId++, ...result.data }; users.push(user); res.status(201).json(user); }); app.get('/users/:id', (req: Request, res: Response) => { const user = users.find(u => u.id === Number(req.params.id)); if (!user) return res.status(404).json({ message: 'User not found' }); res.json(user); }); app.delete('/users/:id', (req: Request, res: Response) => { const idx = users.findIndex(u => u.id === Number(req.params.id)); if (idx === -1) return res.status(404).json({ message: 'User not found' }); users.splice(idx, 1); res.status(204).send(); }); // Express v5: async errors now propagate to error middleware automatically app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { res.status(500).json({ message: err.message }); }); app.listen(3000); FASTIFY V5 (BUILT-IN JSON SCHEMA)
// server.ts import Fastify from 'fastify'; const app = Fastify({ logger: false }); const users: { id: number; name: string; email: string }[] = []; let nextId = 1; const bodySchema = { type: 'object', required: ['name', 'email'], properties: { name: { type: 'string', minLength: 1 }, email: { type: 'string', minLength: 3 }, }, additionalProperties: false, }; app.get('/users', async () => users); app.post('/users', { schema: { body: bodySchema } }, async (request, reply) => { const body = request.body as { name: string; email: string }; const user = { id: nextId++, ...body }; users.push(user); return reply.status(201).send(user); }); app.get<{ Params: { id: string } }>('/users/:id', async (request, reply) => { const user = users.find(u => u.id === Number(request.params.id)); if (!user) return reply.status(404).send({ message: 'User not found' }); return user; }); app.delete<{ Params: { id: string } }>('/users/:id', async (request, reply) => { const idx = users.findIndex(u => u.id === Number(request.params.id)); if (idx === -1) return reply.status(404).send({ message: 'User not found' }); users.splice(idx, 1); return reply.status(204).send(); }); app.setErrorHandler((error, _request, reply) => { reply.status(error.statusCode ?? 500).send({ message: error.message }); }); app.listen({ port: 3000 }); NESTJS V11 (CLASS-VALIDATOR + VALIDATIONPIPE)
// users/dto/create-user.dto.ts import { IsEmail, IsString, MinLength } from 'class-validator'; export class CreateUserDto { @IsString() @MinLength(1) name: string; @IsEmail() email: string; } // users/users.controller.ts import { Controller, Get, Post, Delete, Param, Body, HttpCode } from '@nestjs/common'; import { CreateUserDto } from './dto/create-user.dto'; import { UsersService } from './users.service'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Get() findAll() { return this.usersService.findAll(); } @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); } @Get(':id') findOne(@Param('id') id: string) { return this.usersService.findOne(+id); } @Delete(':id') @HttpCode(204) remove(@Param('id') id: string) { return this.usersService.remove(+id); } } // main.ts - register ValidationPipe globally once; every route gets it automatically import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe({ whitelist: true, // strips unknown properties automatically forbidNonWhitelisted: true, // returns 400 on unknown properties })); await app.listen(3000); } bootstrap(); What the comparison shows: Express and Fastify require you to choose and wire validation per route or per router. NestJS's ValidationPipe with DTOs applies to every endpoint globally. As the application grows from 4 routes to 40, this difference compounds. No new developer can forget to add validation to a new route - the framework prevents it.
Code comparison: JWT auth
EXPRESS - MIDDLEWARE
import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; export const jwtMiddleware = (req: Request, res: Response, next: NextFunction) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ message: 'Unauthorized' }); try { (req as any).user = jwt.verify(token, process.env.JWT_SECRET!); next(); } catch { res.status(401).json({ message: 'Invalid token' }); } }; FASTIFY - PLUGIN HOOK
import fastifyJwt from '@fastify/jwt'; app.register(fastifyJwt, { secret: process.env.JWT_SECRET! }); app.get...