Fastify is the fastest HTTP framework for Node.js — benchmarks show ~75k req/s vs ~35k for Express. Beyond speed, it has built-in JSON Schema validation, compiled response serialization, and a proper plugin system with encapsulation. All the things that require separate packages in Express.
Why Fastify Over Express
Express issues in 2026:
- No built-in request validation — you add zod/joi manually
- No schema-based response serialization —
JSON.stringifyevery response - No TypeScript-first design — types via
@types/expressfeel bolted on - Global middleware — no scoping, plugins interfere with each other
Fastify fixes all of these by default.
Installation
npm install fastify @sinclair/typebox
npm install -D @types/node typescript tsx
npm install @fastify/sensible @fastify/cors @fastify/jwt
First Server
import Fastify from 'fastify'
const server = Fastify({ logger: true })
server.get('/', async () => {
return { status: 'ok', timestamp: Date.now() }
})
server.get<{
Params: { id: string }
}>('/users/:id', async (request) => {
const { id } = request.params // TypeScript knows id is string
return { id, name: 'Alice' }
})
await server.listen({ port: 3000, host: '0.0.0.0' })
No res.send() — just return the value. Fastify serializes it automatically.
Schema Validation with TypeBox
import { Type, Static } from '@sinclair/typebox'
const CreateUserBody = Type.Object({
name: Type.String({ minLength: 1 }),
email: Type.String({ format: 'email' }),
role: Type.Union([Type.Literal('admin'), Type.Literal('user')], { default: 'user' })
})
const UserResponse = Type.Object({
id: Type.String(),
name: Type.String(),
email: Type.String(),
createdAt: Type.String()
})
type CreateUserBody = Static<typeof CreateUserBody>
server.post<{
Body: CreateUserBody
Reply: Static<typeof UserResponse>
}>('/users', {
schema: {
body: CreateUserBody,
response: { 201: UserResponse }
}
}, async (request, reply) => {
const user = await db.user.create({ data: request.body })
reply.status(201)
return user // validated + serialized against UserResponse
})
What you get for free:
- 400 with detailed error messages for invalid requests
- Response serialization strips fields not in the schema (no accidental data leaks)
- Type inference flows from schema to handler
Plugin System
// src/plugins/database.ts
import fp from 'fastify-plugin'
import { PrismaClient } from '@prisma/client'
export default fp(async (fastify) => {
const prisma = new PrismaClient()
await prisma.$connect()
fastify.decorate('db', prisma)
fastify.addHook('onClose', async () => await prisma.$disconnect())
})
declare module 'fastify' {
interface FastifyInstance { db: PrismaClient }
}
// src/routes/posts.ts
import { FastifyPluginAsync } from 'fastify'
import { Type } from '@sinclair/typebox'
const postsRouter: FastifyPluginAsync = async (fastify) => {
fastify.get('/posts', async () => {
return await fastify.db.post.findMany({ where: { published: true } })
})
fastify.post('/posts', {
schema: {
body: Type.Object({
title: Type.String({ minLength: 1 }),
content: Type.String()
})
}
}, async (request, reply) => {
const post = await fastify.db.post.create({ data: request.body })
reply.status(201)
return post
})
}
export default postsRouter
// src/server.ts
await server.register(databasePlugin)
await server.register(postsRouter, { prefix: '/api' })
JWT Authentication
import jwt from '@fastify/jwt'
server.register(jwt, {
secret: process.env.JWT_SECRET!,
sign: { expiresIn: '7d' }
})
server.post('/auth/login', async (request, reply) => {
const { email, password } = request.body as { email: string; password: string }
const user = await db.user.findUnique({ where: { email } })
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return reply.status(401).send({ error: 'Invalid credentials' })
}
const token = server.jwt.sign({ id: user.id, role: user.role })
return { token }
})
server.decorate('authenticate', async (request, reply) => {
try { await request.jwtVerify() }
catch { reply.status(401).send({ error: 'Unauthorized' }) }
})
server.get('/api/profile', {
preHandler: [server.authenticate]
}, async (request) => request.user)
Testing with inject()
No real HTTP server needed for tests:
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { buildServer } from './server'
const server = await buildServer()
afterAll(async () => await server.close())
describe('POST /users', () => {
it('creates with valid data', async () => {
const res = await server.inject({
method: 'POST',
url: '/users',
payload: { name: 'Alice', email: 'alice@example.com' }
})
expect(res.statusCode).toBe(201)
expect(res.json().id).toBeDefined()
})
it('returns 400 for invalid email', async () => {
const res = await server.inject({
method: 'POST',
url: '/users',
payload: { name: 'Bob', email: 'not-an-email' }
})
expect(res.statusCode).toBe(400)
})
})
Fastify vs Express vs Hono
| Fastify | Express | Hono | |
|---|---|---|---|
| Speed | ~75k req/s | ~35k req/s | ~100k+ req/s |
| TypeScript | First-class | @types/express | First-class |
| Validation | Built-in (JSON Schema) | External (zod/joi) | zValidator middleware |
| Plugin system | Encapsulated scope | Global middleware | Middleware stack |
| Runtime | Node.js | Node.js | Multi-runtime |
| Best for | High-throughput Node.js | Legacy/simple apps | Edge / Bun / multi-runtime |
Rule of thumb: Staying on Node.js + need high throughput → Fastify. Multi-runtime or Bun → Hono.
Full article at stacknotice.com/blog/fastify-complete-guide-2026
Top comments (0)