NestJS vs Express vs Fastify in 2026: a practical comparison

Marek Nowicki

Full Stack Developer

2026-07-17

#Development

Time to read

16 mins

In this article

Introduction

The short version

First, understand what each one is

Framework snapshots

What NestJS gives you that the others do not

Mental model: how a request moves through each

Code comparison: CRUD + validation

Code comparison: JWT auth

Performance benchmarks

NestJS project structure

NestJS patterns worth knowing

The adapter choice: Express vs Fastify under NestJS

Picking your framework

What's next: NestJS v12 (planned Q3 2026)

Agency take

Share this article

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.1Fastify 5.8.5NestJS 11.1.24
Node.js minimum18+20+20+
TypeScriptOptionalOptionalFirst-class
DI container✔️
Module system✔️
Built-in validationJSON Schema (AJV)class-validator / Pipes
Auth patternMiddlewarePlugin hookGuard (composable, testable)
OpenAPI / SwaggerManual / pluginManual / pluginAuto-generated from DTOs
Testing utilitiesNone built-inNone built-inTestingModule built-in
Microservices support(gRPC, Kafka, RabbitMQ)
Weekly npm downloads107M¹7.9M10.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:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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

1
2
3
4
5
6
// 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:

1
2
3
@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:

1
2
3
4
5
6
7
8
9
10
11
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:

1
request → middleware A → middleware B → route handler → response

Everything is a function with (req, res, next). You own the chain.

Fastify:

1
request → onRequest hooks → JSON Schema validation → handler → serialization → response

Validation runs at the framework level before your handler. Invalid bodies are rejected automatically.

NestJS:

1
2
3
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 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)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 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)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import fastifyJwt from '@fastify/jwt';

app.register(fastifyJwt, { secret: process.env.JWT_SECRET! });

app.get('/protected', {
  preHandler: async (request, reply) => {
    try {
      await request.jwtVerify();
    } catch (err) {
      reply.send(err);
    }
  }
}, async (request) => {
  return { user: request.user };
});

NESTJS — COMPOSABLE GUARD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// auth/jwt.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';

@Injectable()
export class JwtGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    const auth = req.headers['authorization'];
    if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException();
    try {
      req.user = jwt.verify(auth.slice(7), process.env.JWT_SECRET!);
      return true;
    } catch {
      throw new UnauthorizedException();
    }
  }
}

// Composing multiple guards on a single route is one line:
// @UseGuards(JwtGuard, RolesGuard)
// @Roles('admin')
// @Delete(':id')
// remove(@Param('id') id: string) { ... }

// Unit testing a guard requires no HTTP server:
// const guard = new JwtGuard();
// const context = createMockExecutionContext({ authorization: 'Bearer valid-token' });
// expect(guard.canActivate(context)).toBe(true);

The NestJS guard model pays off when you need to compose auth logic: JWT check, role check, rate limit check, feature flag check. In Express, that is four middleware functions you must apply in the right order to the right routes and remember to test each combination. In NestJS, it is @UseGuards(JwtGuard, RolesGuard) and each guard is independently unit-testable.

Performance benchmarks

These numbers were run on Apple Silicon, Node.js 24.15.0, using autocannon, 100 concurrent connections, 20-second runs. No external I/O, in-memory data.

GET / — BARE JSON RESPONSE

Frameworkreq/sp50p99
Fastify 5.8.5100,301<1 ms1 ms
Express 5.2.153,4001 ms3 ms
NestJS 11 + Fastify adapter41,1591 ms7 ms
NestJS 11 + Express adapter33,3442 ms11 ms

POST /VALIDATE — JSON BODY PARSE + FRAMEWORK-NATIVE VALIDATION

Fastify: built-in JSON Schema (AJV). Express: manual field checks. NestJS: class-validator + ValidationPipe.

Frameworkreq/sp50p99
Fastify 5.8.542,7201 ms7 ms
Express 5.2.128,7643 ms7 ms
NestJS 11 + Fastify adapter19,6934 ms14 ms
NestJS 11 + Express adapter17,4324 ms22 ms

POST /QUERY — VALIDATE + 10MS SIMULATED DATABASE CALL

Same validation as above, with a 10ms async wait added to simulate a real indexed database query. This is what production throughput actually looks like.

Frameworkreq/sp50p99
NestJS 11 + Express adapter8,81211 ms16 ms
NestJS 11 + Fastify adapter8,92911 ms13 ms
Express 5.2.18,77011 ms13 ms
Fastify 5.8.58,61211 ms16 ms

All four frameworks converge to the same throughput — within margin of error, in the same order as random noise. The theoretical ceiling with 100 connections and 10ms I/O is 10,000 req/s; every framework reaches ~88% of it. The 2.5× gap from the pure-CPU benchmark disappears completely.

COLD START AND IDLE MEMORY

FrameworkCold startIdle RSSIdle heap
Express 5.2.183 ms62 MB8 MB
Fastify 5.8.5137 ms68 MB11 MB
NestJS 11 (Express adapter)570 ms95 MB21 MB
NestJS 11 (Fastify adapter)550 ms95 MB21 MB

READING THE NUMBERS HONESTLY

The pure-CPU benchmarks (GET, POST /validate) show a real gap. Fastify handles 2.5× more requests per second than NestJS when there is nothing else to wait on. That number is accurate and worth knowing.

It is also largely irrelevant to the decision.

The moment you add a database - even a fast indexed query at 10ms - every framework converges to the same throughput. The bottleneck becomes I/O wait time, and the 2–4ms of framework overhead NestJS adds per request disappears into the noise. In production, behind a real database, NestJS and Fastify deliver the same throughput.

NestJS at 17,000 req/s (pure CPU) is already handling 1.5 billion requests per day on a single server. With a real database in the picture, all four frameworks land around the same number, and that number is determined entirely by your database latency and connection pool, not by your choice of Node.js framework.

What you are actually trading by choosing NestJS: some CPU-bound throughput headroom that your server will almost certainly never fully use, in exchange for DI, testability, module boundaries, composable guards, global validation, automatic Swagger generation, and a first-party ecosystem for queues, crons, microservices, and everything else a real backend eventually needs.

The cold start story is different. NestJS's ~570ms startup is the DI container initialising, decorators resolving, and modules wiring. For a long-running API server it is paid once at deploy. For serverless or short-lived containers where cold start affects every request, it is a genuine cost. Fastify at 137ms and Express at 83ms are significantly better in those environments.

Switching to the Fastify adapter inside NestJS gives you around 20% better throughput on CPU-bound routes. With I/O in the picture, that 20% gap is barely measurable. The Fastify adapter is still worth using on greenfield projects  but "for performance" is the wrong reason. The right reason is that Fastify is a better underlying HTTP engine.

On benchmarks you will find elsewhere: One widely cited comparison claims NestJS outperforms Express on throughput. Our numbers - and the architectural reality - do not support that. NestJS's Express adapter runs on top of Express; it cannot be faster. If you see NestJS above raw Express in a benchmark, look closely at what is being measured.

NestJS project structure

A production NestJS project follows a consistent, predictable shape that any NestJS developer can navigate immediately:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
src/
  main.ts                       # bootstrap — global pipes, guards, filters, interceptors
  app.module.ts                 # root module
  common/
    filters/                    # AllExceptionsFilter
    guards/                     # JwtGuard, RolesGuard
    interceptors/               # LoggingInterceptor, TransformInterceptor
    decorators/                 # @CurrentUser(), @Public(), @Roles()
  modules/
    users/
      users.module.ts
      users.controller.ts
      users.service.ts
      dto/
        create-user.dto.ts
        update-user.dto.ts
    auth/
      auth.module.ts
      auth.service.ts
      strategies/               # JWT strategy
  prisma/
    prisma.service.ts           # extends PrismaClient, implements OnModuleInit
    prisma.module.ts

A new developer joining the project knows exactly where to look for any piece of logic. common/guards/ has the auth guards. modules/users/ has the users domain. The structure is the documentation.

DATABASE: PRISMA (RECOMMENDED) VS TYPEORM

In 2026 most new NestJS projects reach for Prisma:

1
2
3
4
5
6
7
8
9
10
// prisma/prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}

TypeORM remains common in established codebases and still works well with NestJS. The main 2026 consideration: TypeORM's synchronize: true setting auto-migrates schema at startup and should never be enabled in production.

GLOBAL EXCEPTION FILTER

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// common/filters/all-exceptions.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx    = host.switchToHttp();
    const res    = ctx.getResponse();
    const status = exception instanceof HttpException
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR;
    const message = exception instanceof HttpException
      ? exception.getResponse()
      : 'Internal server error';
    res.status(status).json({ statusCode: status, message });
  }
}
// Register once in main.ts: app.useGlobalFilters(new AllExceptionsFilter());

Every unhandled exception in the application now returns a consistent JSON shape. No try/catch in individual route handlers, no risk of raw error objects leaking to the client.

NestJS patterns worth knowing

These are not gotchas - they are architectural decisions NestJS makes explicit that other frameworks leave implicit.

Circular module dependencies surface at startup, not runtime

If UsersModule imports AuthModule and AuthModule imports UsersModule, NestJS throws at bootstrap:

1
Error: Nest can't resolve dependencies of UserService (?)

This is NestJS catching a circular dependency before your application takes a single request. The fix is forwardRef(), or - better - rethinking whether the two modules should be split differently. Express would silently handle this until something breaks at runtime.

class-validator and the validation performance trade-off

class-validator uses TypeScript reflection metadata, which has runtime cost. Our benchmarks show it reduces throughput on a three-field POST from ~28k to ~17k req/s compared to Express with manual validation. What you gain is automatic, consistent, globally applied validation across every endpoint - no per-route Zod schema, no forgotten validation on a new route, no inconsistent error shapes. For most APIs the trade-off is straightforwardly worth it.

If you hit a specific endpoint where validation is genuinely a throughput bottleneck, you can bypass ValidationPipe on that route and use a custom pipe backed by Zod or raw AJV instead. The framework supports this without requiring architectural changes.

RequestScoped providers: a deliberate choice, not a default

1
2
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {}

Request-scoped providers recreate the injection subtree on every request, which is expensive. The default is singleton scope (the right choice for almost everything). Request scope exists for cases where you genuinely need per-request context - distributed tracing with request-scoped loggers, per-request tenancy in multi-tenant systems. Use it intentionally.

Lifecycle hooks have a defined order

OnModuleInit fires per-module during initialisation. OnApplicationBootstrap fires once after all modules are ready. If your Prisma service connects in OnModuleInit and another service depends on it during initialisation, use OnApplicationBootstrap in the dependent service to guarantee the connection is established first.

The adapter choice: Express vs Fastify under NestJS

WorkloadNestJS + ExpressNestJS + FastifyGain
GET /33,344 req/s41,159 req/s+23%
POST /validate17,432 req/s19,693 req/s+13%
Cold start570 ms550 msnegligible
Idle RSS95 MB95 MBsame

The Fastify adapter consistently delivers 15–23% more throughput. The catch: some Express-specific middleware does not work under the Fastify adapter. Older Passport strategies, certain session packages, and any middleware that directly modifies the Express req/res objects will need Fastify equivalents (@fastify/passport, @fastify/session).

For a new greenfield project with no Express dependencies: use the Fastify adapter, accept the marginal tradeoff in ecosystem breadth, and get the performance headroom. For an existing project with established middleware dependencies: stay on Express adapter and save the migration effort for something with more impact.

Picking your framework

NestJS is the right default for backend work in Node.js when:

  • You are building a real application - auth, modules, services, repositories, business logic
  • More than one developer will touch the codebase
  • You need unit-testable services
  • You want consistent patterns across every module, not just the ones the original author wrote
  • You are thinking beyond HTTP: queues, scheduled jobs, microservices, event-driven architecture - the NestJS ecosystem covers all of it with the same mental model

Fastify is the right choice when:

  • Throughput is a primary constraint - IoT ingestion, high-frequency trading, event processing at scale
  • You are writing a single-purpose HTTP service with a small surface area
  • Cold start time matters (serverless, edge functions)
  • You want schema-validated endpoints with near-zero validation overhead

Express is the right choice when:

  • You are prototyping or scoping
  • You are maintaining an existing Express codebase where the migration cost outweighs the benefit
  • You need deep compatibility with a specific Express-only library
  • The service is genuinely small enough that framework structure adds noise, not clarity

What's next: NestJS v12 (planned Q3 2026)

NestJS v12 is expected in Q3 2026 with three changes worth tracking:

  • Native ESM - CommonJS workarounds in monorepos become unnecessary
  • Vitest as the default test runner - replacing Jest in the scaffolded template
  • Standard Schema / Zod in decorators - class-validator may no longer be the only first-class DTO validation option. If this lands, the validation performance story changes: Zod's runtime cost is lower and its TypeScript inference is tighter

If you are designing an application today that will be in production in 12 months, keep DTO validation encapsulated. The ValidationPipe pattern is stable; the underlying validator may swap.

Agency take

NestJS is our default for client backend work. Not because it is the most performant, but because it is the one that holds up when the project gets handed to a new developer six months later, when the team grows from two to five, when you need to write tests for a feature built by someone else. The structure NestJS enforces is the kind you would build yourself in Express eventually - it just starts from day one instead of after the first painful refactor.

We reach for Fastify when a service has an explicit throughput requirement or when serverless cold start matters. We reach for Express when scope is tight and structure would be overhead rather than investment.

The throughput gap in the synthetic benchmarks is real. Once you add a real database - even a fast one - all four frameworks in these benchmarks land within a few percent of each other. The bottleneck is I/O, not the framework. We measured it: at 10ms simulated query time, Fastify, Express, NestJS on Express, and NestJS on Fastify all hit ~8,700–8,900 req/s. The 2.5× synthetic gap becomes noise.

Pick the framework your team can build on confidently. That is NestJS for most teams doing serious backend work in 2026.

Marek Nowicki

Full Stack Developer

Share this post

Related posts

Want to light up your ideas with us?

Kickstart your new project with us in just 1 step!

Prefer to call or write a traditional e-mail?

Dev and Deliver

sp. z o.o. sp. k.

Address

Józefitów 8

30-039 Cracow, Poland

VAT EU

PL9452214307

Regon

368739409

KRS

94552994