APIs
|stacknotice.com
14 min left|
0%
|2,800 words
APIs

NestJS vs Fastify (2026): When Full Framework Beats Micro-Framework

NestJS enforces architecture and DI across your whole codebase. Fastify gives you raw throughput and full flexibility. Which you pick depends on team size more than benchmarks.

C
Carlos Oliva
Software Developer
August 10, 202614 min read
Share:
NestJS vs Fastify (2026): When Full Framework Beats Micro-Framework

NestJS and Fastify are not competing for the same niche. Fastify is an HTTP framework — it handles routing, request lifecycle, and JSON serialization. NestJS is an application framework — it handles all of that plus dependency injection, module encapsulation, decorators, and a strong opinion about how every file in your codebase should be organized.

The comparison isn't really about performance numbers. It's about whether you want the framework to enforce architecture across your team, or whether your team can enforce it themselves.

There's also a third option worth knowing up front: NestJS can use Fastify as its HTTP adapter instead of Express. You get NestJS's DI system with Fastify's serialization speed underneath.

What Each Tool Actually Does

Fastify optimizes for throughput. Its standout features: JSON schema compilation (2-3x faster than JSON.stringify), a plugin system with proper encapsulation, and TypeScript-first types. You own the architecture.

NestJS optimizes for consistency across a growing codebase. Modules, controllers, services, guards, interceptors, pipes — every concept has a defined place and a decorator. A developer joining a NestJS project on day one knows where to look for anything.

# Fastify — minimal scaffolding, you decide the structure
npm install fastify @sinclair/typebox
 
# NestJS — full application scaffold with enforced structure
npm install -g @nestjs/cli
nest new my-api
# Creates: src/main.ts, app.module.ts, app.controller.ts, app.service.ts
# Generate a full CRUD resource:
nest generate resource users
# Creates: module, controller, service, DTOs, entity — all wired together

Architecture: Yours vs. Theirs

Fastify: Structure Is Your Responsibility

A typical production Fastify application:

// src/index.ts
import Fastify from 'fastify'
import { userRoutes } from './routes/users'
import { authPlugin } from './plugins/auth'
import { dbPlugin } from './plugins/db'
 
const fastify = Fastify({ logger: true })
 
await fastify.register(dbPlugin)
await fastify.register(authPlugin)
await fastify.register(userRoutes, { prefix: '/api/users' })
 
await fastify.listen({ port: 3000, host: '0.0.0.0' })
// src/plugins/db.ts
import fp from 'fastify-plugin'
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from '../db/schema'
 
export const dbPlugin = fp(async (fastify) => {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL })
  const db = drizzle(pool, { schema })
 
  fastify.decorate('db', db)
 
  fastify.addHook('onClose', async () => {
    await pool.end()
  })
})
 
// Augment FastifyInstance type so all routes get db typesafe
declare module 'fastify' {
  interface FastifyInstance {
    db: ReturnType<typeof drizzle<typeof schema>>
  }
}
// src/routes/users.ts
import type { FastifyPluginAsync } from 'fastify'
import { Type } from '@sinclair/typebox'
 
const CreateUserBody = Type.Object({
  name: Type.String({ minLength: 1, maxLength: 100 }),
  email: Type.String({ format: 'email' })
})
 
const UserResponse = Type.Object({
  id: Type.String(),
  name: Type.String(),
  email: Type.String(),
  createdAt: Type.String()
})
 
export const userRoutes: FastifyPluginAsync = async (fastify) => {
  fastify.post('/', {
    schema: {
      body: CreateUserBody,
      response: { 201: UserResponse }
    }
  }, async (request, reply) => {
    const { name, email } = request.body
    const existing = await fastify.db.query.users.findFirst({
      where: (u, { eq }) => eq(u.email, email)
    })
    if (existing) return reply.status(409).send({ error: 'Email already in use' })
 
    const [user] = await fastify.db.insert(users).values({ name, email }).returning()
    return reply.status(201).send(user)
  })
 
  fastify.get('/:id', async (request, reply) => {
    const user = await fastify.db.query.users.findFirst({
      where: (u, { eq }) => eq(u.id, request.params.id)
    })
    if (!user) return reply.status(404).send({ error: 'Not found' })
    return user
  })
}

This is clean. For a senior developer or a small team with strong conventions, it works well. The problem appears at scale: new team members inventing their own structures, services getting imported in multiple places without DI, and architectural drift accumulating over months.

NestJS: The Framework Decides

NestJS enforces a Module → Controller → Service hierarchy. Every concept has a decorator and a defined role.

// src/users/users.module.ts
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { UsersController } from './users.controller'
import { UsersService } from './users.service'
import { User } from './user.entity'
 
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],  // Other modules can inject this service
})
export class UsersModule {}
// src/users/users.service.ts
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { User } from './user.entity'
import { CreateUserDto } from './dto/create-user.dto'
 
@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepo: Repository<User>
  ) {}
 
  async create(dto: CreateUserDto): Promise<User> {
    const existing = await this.userRepo.findOne({ where: { email: dto.email } })
    if (existing) throw new ConflictException('Email already in use')
 
    const user = this.userRepo.create(dto)
    return this.userRepo.save(user)
  }
 
  async findById(id: string): Promise<User> {
    const user = await this.userRepo.findOne({ where: { id } })
    if (!user) throw new NotFoundException(`User ${id} not found`)
    return user
  }
 
  async findAll(page = 1, limit = 20): Promise<{ users: User[]; total: number }> {
    const [users, total] = await this.userRepo.findAndCount({
      skip: (page - 1) * limit,
      take: limit,
      order: { createdAt: 'DESC' }
    })
    return { users, total }
  }
}
// src/users/users.controller.ts
import { Controller, Post, Get, Param, Body, Query, UseGuards, HttpCode } from '@nestjs/common'
import { ApiTags, ApiBearerAuth, ApiCreatedResponse } from '@nestjs/swagger'
import { UsersService } from './users.service'
import { CreateUserDto } from './dto/create-user.dto'
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'
import { UserResponseDto } from './dto/user-response.dto'
 
@ApiTags('users')
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
 
  @Post()
  @HttpCode(201)
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @ApiCreatedResponse({ type: UserResponseDto })
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto)
  }
 
  @Get(':id')
  findById(@Param('id') id: string) {
    return this.usersService.findById(id)
  }
 
  @Get()
  findAll(
    @Query('page') page?: string,
    @Query('limit') limit?: string
  ) {
    return this.usersService.findAll(Number(page) || 1, Number(limit) || 20)
  }
}

UsersController has no database logic. UsersService has no HTTP knowledge. A developer who has never seen this codebase knows exactly where to find the business logic for a user endpoint. That predictability has real value on a team.

Dependency Injection: The Real Differentiator

NestJS's DI container is what separates it from Fastify at a structural level. It changes how you test.

// NestJS unit test — swap real dependencies for mocks without touching production code
import { Test } from '@nestjs/testing'
import { getRepositoryToken } from '@nestjs/typeorm'
 
describe('UsersService', () => {
  let service: UsersService
  const mockRepo = {
    findOne: jest.fn(),
    create: jest.fn((dto) => dto),
    save: jest.fn((user) => ({ id: 'uuid-123', ...user }))
  }
 
  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        UsersService,
        { provide: getRepositoryToken(User), useValue: mockRepo }
      ]
    }).compile()
 
    service = module.get(UsersService)
  })
 
  it('throws ConflictException when email is taken', async () => {
    mockRepo.findOne.mockResolvedValueOnce({ id: 'existing', email: 'taken@test.com' })
    await expect(service.create({ name: 'Test', email: 'taken@test.com' }))
      .rejects.toThrow(ConflictException)
  })
 
  it('creates and returns user', async () => {
    mockRepo.findOne.mockResolvedValueOnce(null)
    const result = await service.create({ name: 'Alice', email: 'alice@test.com' })
    expect(result.id).toBeDefined()
    expect(mockRepo.save).toHaveBeenCalled()
  })
})

Fastify doesn't have a built-in DI container. Unit testing a service means either passing mocked dependencies directly or using a DI library like awilix or tsyringe alongside it.

// Fastify service test — manual mock injection
import { createUserService } from '../services/users'
 
const mockDb = {
  query: { users: { findFirst: jest.fn() } },
  insert: jest.fn().mockReturnValue({ values: jest.fn().mockReturnValue({ returning: jest.fn().mockResolvedValue([{ id: 'uuid', name: 'Alice' }]) }) })
}
 
it('creates user when email is available', async () => {
  mockDb.query.users.findFirst.mockResolvedValueOnce(null)
  const result = await createUserService(mockDb as any, { name: 'Alice', email: 'alice@test.com' })
  expect(result.id).toBeDefined()
})

Both work. NestJS's DI makes the test setup more consistent across the whole codebase. Fastify gives you flexibility to choose your own approach.

NestJS + Fastify Adapter: Both at Once

NestJS defaults to Express as its HTTP layer. Swapping to Fastify gives you NestJS's architecture with Fastify's compiled serialization.

// main.ts — switch the adapter at startup
import { NestFactory } from '@nestjs/core'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { AppModule } from './app.module'
import { ValidationPipe } from '@nestjs/common'
 
async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter({ logger: true })
  )
 
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,     // Strip properties not in the DTO
    transform: true,     // Auto-transform query strings to numbers/booleans
    forbidNonWhitelisted: true
  }))
 
  await app.listen(3000, '0.0.0.0')
}
bootstrap()

The throughput improvement over NestJS + Express is meaningful: roughly 30-50% more requests per second on JSON-heavy routes. The trade-off: some Express-specific middleware won't work with the Fastify adapter — verify compatibility before switching an existing app.

Performance

Setup~ThroughputNotes
Fastify (standalone)~75,000 req/sTypeBox serialization, minimal overhead
NestJS + Fastify adapter~50,000 req/sDI resolves at startup, not per request
NestJS + Express (default)~28,000 req/sExpress middleware chain overhead

A few things to put these numbers in context:

  • NestJS's DI container resolves dependencies at startup, not on every request — the per-request overhead is much lower than the architecture complexity suggests
  • For most production APIs, database latency (5-50ms) dominates over framework overhead (0.1-0.5ms)
  • The gap becomes meaningful at sustained high load: 10k+ req/s where framework CPU usage actually shows up in profiling

Ecosystem Depth

NestJS has:

  • @nestjs/swagger — decorators auto-generate OpenAPI docs, no separate spec file
  • @nestjs/microservices — gRPC, MQTT, Kafka, RabbitMQ, NATS transports built in
  • @nestjs/graphql — code-first or schema-first GraphQL, works with Apollo or Mercurius
  • @nestjs/websockets — WebSocket gateway with the same DI model
  • @nestjs/schedule — cron jobs as decorated methods
  • @nestjs/cache-manager — Redis/in-memory cache with a decorator
  • CLI for generating every file type consistently
// Swagger docs — zero extra work if you use DTOs with decorators
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
import { IsString, IsEmail, MinLength, IsEnum, IsOptional } from 'class-validator'
 
export class CreateUserDto {
  @ApiProperty({ description: 'Full name', minLength: 1 })
  @IsString()
  @MinLength(1)
  name: string
 
  @ApiProperty({ description: 'Email address', format: 'email' })
  @IsEmail()
  email: string
 
  @ApiPropertyOptional({ enum: ['USER', 'ADMIN'], default: 'USER' })
  @IsEnum(['USER', 'ADMIN'])
  @IsOptional()
  role?: 'USER' | 'ADMIN'
}
// swagger-ui at /api automatically documents this endpoint

Fastify has:

  • @fastify/cors, @fastify/jwt, @fastify/multipart, @fastify/rate-limit — all production-ready
  • @fastify/swagger — OpenAPI generation via schema objects (not decorators)
  • fastify-plugin — proper encapsulation for plugins
  • Lighter dependency tree, faster cold starts, smaller Docker images

NestJS's ecosystem is broader. Fastify's is leaner. If you need microservices, GraphQL, and auto-generated Swagger from day one, NestJS has all of it. If you're building a focused REST API and want to stay lean, Fastify has what you need.

The Same Endpoint, Both Tools

POST /users — validation, conflict check, create, 201 response:

// Fastify
fastify.post('/', {
  schema: {
    body: Type.Object({
      name: Type.String({ minLength: 1 }),
      email: Type.String({ format: 'email' })
    }),
    response: { 201: UserResponse }
  }
}, async (request, reply) => {
  const existing = await fastify.db.query.users.findFirst({
    where: (u, { eq }) => eq(u.email, request.body.email)
  })
  if (existing) return reply.status(409).send({ error: 'Email already in use' })
  const [user] = await fastify.db.insert(users).values(request.body).returning()
  return reply.status(201).send(user)
})
 
// NestJS — split across controller and service
// Controller:
@Post() @HttpCode(201) @UseGuards(JwtAuthGuard)
create(@Body() dto: CreateUserDto) {
  return this.usersService.create(dto)
}
 
// Service:
async create(dto: CreateUserDto): Promise<User> {
  const existing = await this.userRepo.findOne({ where: { email: dto.email } })
  if (existing) throw new ConflictException('Email already in use')
  return this.userRepo.save(this.userRepo.create(dto))
}

Fastify puts everything in one place. NestJS splits it by responsibility — HTTP handling in the controller, business logic in the service. Both are valid. They optimize for different things: Fastify for proximity of context, NestJS for separation of concerns.

Decision Framework

Choose Fastify if:

  • Small team (1-5 devs) with strong architectural opinions of their own
  • You need maximum throughput and minimal abstraction overhead
  • Building a focused microservice with a clear, bounded scope
  • Cold start time matters (Fastify boots significantly faster than NestJS)
  • Migrating from Express and want a familiar mental model with a real performance upgrade

Choose NestJS if:

  • Team of 5+ with mixed experience levels where consistency matters
  • You need enforced architecture to prevent drift as the codebase grows
  • Enterprise feature set: microservices, GraphQL, WebSockets, gRPC out of the box
  • Coming from a Spring Boot or Angular background — the mental model maps directly
  • You want auto-generated Swagger docs without maintaining a separate spec

Choose NestJS + Fastify adapter if:

  • You want NestJS's DI and module system but need better throughput than Express delivers
  • Existing NestJS app where Express has become a bottleneck under load

The clearest signal: if someone joins your team and can find where a given endpoint's logic lives in under two minutes, Fastify is working. If that takes ten minutes of asking around, it's time to consider the structure NestJS enforces automatically.

For a wider framework comparison including Hono and Express, see the Hono vs Express vs Fastify breakdown. For the full NestJS production setup, including auth guards, interceptors, and Docker deployment, see the NestJS complete guide. For the Fastify side in depth — plugins, TypeBox schemas, and the lifecycle model — the Fastify complete guide covers it from scratch.

#nestjs#fastify#nodejs#typescript#backend
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.