Fastify is the fastest HTTP framework for Node.js. Not marginally faster — benchmarks consistently show 2x the throughput of Express on identical hardware, thanks to a lean core, JSON schema validation that compiles to optimized code, and a serialization engine that avoids the overhead of JSON.stringify.
Beyond raw speed, Fastify is TypeScript-first, has a plugin system designed for large applications, and ships with schema-based request validation and response serialization built in — things that require separate packages (Zod, express-validator, ajv) in an Express app.
This guide covers Fastify from scratch through production patterns: routing, validation, plugins, hooks, authentication, and testing.
Why Fastify Over Express
Express problems in 2026:
- No built-in request validation — you add
zod,joi, orexpress-validatormanually - No schema-based response serialization —
JSON.stringifyevery response - No TypeScript-first design — types are bolted on via
@types/express - Callback-based API — async support works but wasn't designed for it
- No plugin isolation — middleware is global, no scoping
What Fastify does differently:
- Built-in JSON Schema validation for request body, params, query, and headers
- Compiled serialization — Fastify generates optimized serializers per route, 2x faster response encoding
- Plugin system with encapsulation — plugins register routes and decorators in scope, not globally
- Async/await native — every route handler returns a value, no
res.send()required - Full TypeScript generics — route params, body, and response types flow through
When Express still makes sense:
- Extremely simple proxy or middleware servers
- Large existing Express codebases where migration cost exceeds benefit
- Teams more comfortable with middleware patterns than plugin systems
For new Node.js APIs, Fastify is the better default unless you're also considering Bun + Hono, which goes further in performance.
Installation
npm install fastify
npm install -D @types/node typescript tsx
# Optional but recommended
npm install @fastify/sensible # 404/500 shortcuts
npm install @fastify/cors
npm install @fastify/jwt
npm install @fastify/multipartFirst Server
// src/server.ts
import Fastify from 'fastify'
const server = Fastify({
logger: {
level: 'info',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty' }
: undefined
}
})
server.get('/', async (request, reply) => {
return { status: 'ok', timestamp: Date.now() }
})
server.get<{
Params: { id: string }
}>('/users/:id', async (request, reply) => {
const { id } = request.params
// TypeScript knows id is a string
return { id, name: 'Alice' }
})
const start = async () => {
try {
await server.listen({ port: 3000, host: '0.0.0.0' })
console.log('Server running at http://localhost:3000')
} catch (err) {
server.log.error(err)
process.exit(1)
}
}
start()Run with:
npx tsx src/server.ts
# or in watch mode:
npx tsx --watch src/server.tsNote: you don't call reply.send() — just return the value. Fastify handles serialization automatically.
Schema Validation
Fastify validates requests and serializes responses using JSON Schema. Define the schema on each route:
import Fastify, { FastifyInstance } from 'fastify'
import { Type, Static } from '@sinclair/typebox'
const server = Fastify({ logger: true })
// Define schemas with TypeBox (type-safe JSON Schema)
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(),
role: Type.String(),
createdAt: Type.String()
})
type CreateUserBody = Static<typeof CreateUserBody>
type UserResponse = Static<typeof UserResponse>
server.post<{
Body: CreateUserBody
Reply: UserResponse
}>('/users', {
schema: {
body: CreateUserBody,
response: {
201: UserResponse
}
}
}, async (request, reply) => {
const { name, email, role } = request.body
// Body is fully typed — TypeScript knows name, email, role
const user = await db.user.create({ data: { name, email, role } })
reply.status(201)
return user // serialized against UserResponse schema
})What validation does for you:
- Sends 400 with detailed error messages if the request body is invalid
- Strips extra fields not in the schema (by default)
- Converts types (e.g., query string
?limit=10comes as string, schema can coerce to number) - Response serialization omits fields not in the response schema — prevents accidentally leaking sensitive data
Install TypeBox:
npm install @sinclair/typeboxRouting and Route Options
// GET with query params
server.get<{
Querystring: { page?: number; limit?: number; search?: string }
}>('/posts', {
schema: {
querystring: Type.Object({
page: Type.Optional(Type.Integer({ minimum: 1, default: 1 })),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 20 })),
search: Type.Optional(Type.String())
})
}
}, async (request) => {
const { page = 1, limit = 20, search } = request.query
const posts = await db.post.findMany({
where: search ? { title: { contains: search } } : { published: true },
skip: (page - 1) * limit,
take: limit
})
return { posts, page, limit }
})
// DELETE with params
server.delete<{
Params: { id: string }
Reply: { success: boolean }
}>('/posts/:id', async (request, reply) => {
const { id } = request.params
await db.post.delete({ where: { id } })
reply.status(204)
return { success: true }
})
// PATCH with both params and body
server.patch<{
Params: { id: string }
Body: { title?: string; published?: boolean }
}>('/posts/:id', {
schema: {
params: Type.Object({ id: Type.String() }),
body: Type.Object({
title: Type.Optional(Type.String({ minLength: 1 })),
published: Type.Optional(Type.Boolean())
})
}
}, async (request) => {
const { id } = request.params
return await db.post.update({ where: { id }, data: request.body })
})Plugin System
Plugins are Fastify's version of Express routers — but with encapsulation. A plugin can register routes, decorators, and hooks that are scoped to that plugin's context, not the entire application.
// src/plugins/database.ts
import fp from 'fastify-plugin'
import { PrismaClient } from '@prisma/client'
// fp() makes the plugin available in parent scope (not encapsulated)
export default fp(async (fastify) => {
const prisma = new PrismaClient()
await prisma.$connect()
fastify.decorate('db', prisma)
fastify.addHook('onClose', async () => {
await prisma.$disconnect()
})
})
// Extend Fastify type so TypeScript knows about db
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.db is available here because the db plugin is registered globally
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
import Fastify from 'fastify'
import databasePlugin from './plugins/database'
import postsRouter from './routes/posts'
const server = Fastify({ logger: true })
// Register plugins — order matters
await server.register(databasePlugin)
await server.register(postsRouter, { prefix: '/api' })
await server.listen({ port: 3000, host: '0.0.0.0' })Hooks
Fastify has a lifecycle with hooks at each stage:
// onRequest — runs before routing
server.addHook('onRequest', async (request, reply) => {
request.log.info({ url: request.url }, 'incoming request')
})
// preHandler — runs after routing, before the handler
server.addHook('preHandler', async (request, reply) => {
// Authentication check
if (request.routeOptions.config?.requiresAuth) {
const token = request.headers.authorization?.split(' ')[1]
if (!token) {
return reply.status(401).send({ error: 'Unauthorized' })
}
// Verify token and attach user...
}
})
// onSend — runs after handler, before sending response
server.addHook('onSend', async (request, reply, payload) => {
reply.header('X-Response-Time', Date.now() - request.startTime)
return payload
})
// onError — global error handler
server.addHook('onError', async (request, reply, error) => {
request.log.error(error)
})Authentication with JWT
npm install @fastify/jwtimport jwt from '@fastify/jwt'
server.register(jwt, {
secret: process.env.JWT_SECRET!,
sign: { expiresIn: '7d' }
})
// Login route — generates token
server.post<{
Body: { email: string; password: string }
}>('/auth/login', {
schema: {
body: Type.Object({
email: Type.String({ format: 'email' }),
password: Type.String({ minLength: 8 })
})
}
}, async (request, reply) => {
const { email, password } = request.body
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, email: user.email, role: user.role })
return { token, user: { id: user.id, email: user.email } }
})
// Protect routes with a decorator
server.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify()
} catch {
reply.status(401).send({ error: 'Unauthorized' })
}
})
// Protected route
server.get('/api/profile', {
preHandler: [server.authenticate]
}, async (request) => {
return request.user // typed as the JWT payload
})Error Handling
import sensible from '@fastify/sensible'
server.register(sensible)
// Custom error handler
server.setErrorHandler((error, request, reply) => {
const statusCode = error.statusCode ?? 500
if (statusCode >= 500) {
request.log.error(error)
}
reply.status(statusCode).send({
error: statusCode >= 500 ? 'Internal Server Error' : error.message,
statusCode
})
})
// Using sensible shortcuts
server.get('/posts/:id', async (request, reply) => {
const post = await db.post.findUnique({ where: { id: request.params.id } })
if (!post) {
return reply.notFound('Post not found') // 404 via sensible
}
return post
})Testing
Fastify has a built-in inject method for testing without starting a real HTTP server:
// src/server.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { buildServer } from './server'
let server: Awaited<ReturnType<typeof buildServer>>
beforeAll(async () => {
server = await buildServer()
})
afterAll(async () => {
await server.close()
})
describe('POST /users', () => {
it('creates a user with valid data', async () => {
const response = await server.inject({
method: 'POST',
url: '/users',
payload: {
name: 'Alice',
email: 'alice@example.com'
}
})
expect(response.statusCode).toBe(201)
const body = response.json()
expect(body.id).toBeDefined()
expect(body.email).toBe('alice@example.com')
})
it('returns 400 for invalid email', async () => {
const response = await server.inject({
method: 'POST',
url: '/users',
payload: { name: 'Bob', email: 'not-an-email' }
})
expect(response.statusCode).toBe(400)
})
it('returns 400 for missing name', async () => {
const response = await server.inject({
method: 'POST',
url: '/users',
payload: { email: 'test@example.com' }
})
expect(response.statusCode).toBe(400)
})
})
describe('GET /users/:id', () => {
it('returns 404 for non-existent user', async () => {
const response = await server.inject({
method: 'GET',
url: '/users/non-existent-id'
})
expect(response.statusCode).toBe(404)
})
})The buildServer factory pattern makes testing clean:
// src/server.ts
export async function buildServer() {
const server = Fastify({ logger: false }) // disable logging in tests
await server.register(databasePlugin)
await server.register(postsRouter, { prefix: '/api' })
return server
}
// Production entry point
if (process.env.NODE_ENV !== 'test') {
const server = await buildServer()
await server.listen({ port: 3000, host: '0.0.0.0' })
}Fastify vs Express vs Hono
| Fastify | Express | Hono | |
|---|---|---|---|
| Performance | ~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 |
| Serialization | Compiled, fast | JSON.stringify | JSON.stringify |
| Plugin system | Encapsulated scope | Global middleware | Middleware stack |
| Runtime | Node.js | Node.js | Multi-runtime (Bun, Deno, Edge) |
| Ecosystem | Mature | Very mature | Growing |
| Learning curve | Medium | Low | Low-Medium |
| Best for | High-throughput Node.js APIs | Legacy apps, simple servers | Edge/Bun/multi-runtime APIs |
Rule of thumb:
- Staying on Node.js, need high throughput → Fastify
- Multi-runtime or Bun → Hono (see Hono.js Complete Guide)
- Large existing codebase → stick with Express and migrate gradually
- Greenfield API server → Fastify if Node.js, Hono if Bun
Fastify's sweet spot is high-throughput Node.js APIs where the built-in schema validation and compiled serialization earn their keep. The plugin system scales better than Express middleware for large teams — encapsulation means plugins don't interfere with each other. If you're writing a new backend service and staying on Node.js, Fastify is the upgrade from Express that actually delivers measurable gains without a framework migration cost.