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 togetherArchitecture: 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 | ~Throughput | Notes |
|---|---|---|
| Fastify (standalone) | ~75,000 req/s | TypeBox serialization, minimal overhead |
| NestJS + Fastify adapter | ~50,000 req/s | DI resolves at startup, not per request |
| NestJS + Express (default) | ~28,000 req/s | Express 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 endpointFastify 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.