Fastify
Fastify is a fast, low-overhead web framework for Node.js built by core Node.js contributors. It emphasizes performance, developer experience, and a plugin-based architecture. Unlike Express, which treats middleware as a linear stack, Fastify uses encapsulated plugins that prevent accidental global state pollution and make large applications easier to test and scale.
This guide covers routes, JSON schema validation, hooks, plugins, decorators, error handling, testing with Node.js 20's built-in test runner, performance comparisons with Express, and production deployment patterns.
Install Fastify in a Node.js 20+ project. The core is tiny; common features come from official plugins such as @fastify/cors, @fastify/helmet, and @fastify/jwt.
| 1 | npm init -y |
| 2 | npm install fastify |
| 3 | npm install @fastify/cors @fastify/helmet @fastify/env |
| 4 | node server.js |
A minimal server configures Pino logging and registers routes; async handlers automatically serialize returned values and send a 200 response.
| 1 | import Fastify from 'fastify'; |
| 2 | |
| 3 | const app = Fastify({ |
| 4 | logger: { level: process.env.LOG_LEVEL || 'info' }, |
| 5 | }); |
| 6 | |
| 7 | app.get('/', async () => ({ hello: 'world' })); |
| 8 | app.get('/health', async () => ({ status: 'ok', uptime: process.uptime() })); |
| 9 | |
| 10 | try { |
| 11 | await app.listen({ port: 3000, host: '0.0.0.0' }); |
| 12 | app.log.info('Server listening'); |
| 13 | } catch (err) { |
| 14 | app.log.error(err); |
| 15 | process.exit(1); |
| 16 | } |
info
Routes use shorthand methods or app.route(options); handlers can be sync or async. The request object exposes params, query, body, headers, and raw; the reply object adds helpers like .send(), .code(), and .header().
| 1 | app.get('/users', async () => [{ id: 1, name: 'Ada' }]); |
| 2 | |
| 3 | app.post('/users', async (request, reply) => { |
| 4 | const user = request.body; |
| 5 | reply.code(201); |
| 6 | return { id: crypto.randomUUID(), ...user }; |
| 7 | }); |
| 8 | |
| 9 | app.route({ |
| 10 | method: 'GET', |
| 11 | url: '/users/:id', |
| 12 | handler: async (request) => { |
| 13 | return { id: request.params.id, name: 'Ada' }; |
| 14 | }, |
| 15 | }); |
| 1 | app.get('/search', async (request, reply) => { |
| 2 | const { q, page = '1' } = request.query; |
| 3 | const pageNum = Number(page); |
| 4 | if (Number.isNaN(pageNum) || pageNum {'<'} 1) { |
| 5 | reply.code(400); |
| 6 | return { error: 'Invalid page number' }; |
| 7 | } |
| 8 | return { query: q, page: pageNum }; |
| 9 | }); |
Fastify validates request bodies, query strings, params, headers, and responses using JSON Schema. Validation runs before the handler via Ajv. Response schemas also enable fast-json-stringify, which serializes much faster than generic JSON.stringify.
| 1 | const createUserSchema = { |
| 2 | body: { |
| 3 | type: 'object', |
| 4 | required: ['email', 'password'], |
| 5 | properties: { |
| 6 | email: { type: 'string', format: 'email' }, |
| 7 | password: { type: 'string', minLength: 12 }, |
| 8 | age: { type: 'integer', minimum: 18, nullable: true }, |
| 9 | roles: { |
| 10 | type: 'array', |
| 11 | items: { type: 'string', enum: ['user', 'admin', 'editor'] }, |
| 12 | maxItems: 5, |
| 13 | }, |
| 14 | }, |
| 15 | additionalProperties: false, |
| 16 | }, |
| 17 | response: { |
| 18 | 201: { |
| 19 | type: 'object', |
| 20 | properties: { |
| 21 | id: { type: 'string' }, |
| 22 | email: { type: 'string' }, |
| 23 | createdAt: { type: 'string' }, |
| 24 | }, |
| 25 | required: ['id', 'email', 'createdAt'], |
| 26 | }, |
| 27 | }, |
| 28 | }; |
| 29 | |
| 30 | app.post('/users', { schema: createUserSchema }, async (request, reply) => { |
| 31 | reply.code(201); |
| 32 | return { |
| 33 | id: crypto.randomUUID(), |
| 34 | email: request.body.email, |
| 35 | createdAt: new Date().toISOString(), |
| 36 | }; |
| 37 | }); |
best practice
Custom Error Handler
When validation fails, Fastify throws a 400 error with a structured payload. Override setErrorHandler to shape error responses and hide internal details from clients.
| 1 | app.setErrorHandler((error, request, reply) => { |
| 2 | if (error.validation) { |
| 3 | reply.code(400); |
| 4 | return { |
| 5 | error: 'Validation failed', |
| 6 | details: error.validation.map((v) => ({ |
| 7 | field: v.instancePath || v.params.missingProperty, |
| 8 | message: v.message, |
| 9 | })), |
| 10 | }; |
| 11 | } |
| 12 | request.log.error(error); |
| 13 | reply.code(error.statusCode || 500); |
| 14 | return { |
| 15 | error: error.code || 'INTERNAL_ERROR', |
| 16 | message: error.statusCode ? error.message : 'Internal server error', |
| 17 | }; |
| 18 | }); |
Plugins are async functions that register routes, hooks, decorators, and child plugins. Each plugin creates an encapsulated scope. Decorators registered in a child plugin do not leak to the parent unless you wrap the plugin with fastify-plugin.
| 1 | // plugins/users.js |
| 2 | async function userRoutes(app, options) { |
| 3 | app.get('/', async () => [{ id: 1, name: 'Ada' }]); |
| 4 | app.get('/:id', async (request) => ({ id: request.params.id, name: 'Ada' })); |
| 5 | } |
| 6 | |
| 7 | export default userRoutes; |
| 8 | |
| 9 | // server.js |
| 10 | import userRoutes from './plugins/users.js'; |
| 11 | await app.register(userRoutes, { prefix: '/users' }); |
warning
| 1 | import fp from 'fastify-plugin'; |
| 2 | |
| 3 | async function dbPlugin(app, options) { |
| 4 | const db = await createConnection(options.url); |
| 5 | app.decorate('db', db); |
| 6 | app.addHook('onClose', async () => db.close()); |
| 7 | } |
| 8 | |
| 9 | export default fp(dbPlugin, { name: 'db-plugin' }); |
| 10 | |
| 11 | // app.db is now available everywhere |
| 12 | app.get('/posts', async () => app.db.query('SELECT * FROM posts LIMIT 10')); |
Hooks intercept the request lifecycle. Common hooks include onRequest, preValidation, preHandler, preSerialization, onSend, and onResponse.
| Hook | When It Runs | Common Use |
|---|---|---|
| onRequest | Before parsing | Request IDs, IP filtering |
| preHandler | After validation | Auth, authorization |
| onSend | Before response is sent | Headers, compression |
| onResponse | Response sent | Metrics, access logs |
| 1 | app.addHook('onRequest', async (request, reply) => { |
| 2 | request.id = crypto.randomUUID(); |
| 3 | reply.header('x-request-id', request.id); |
| 4 | }); |
| 5 | |
| 6 | app.addHook('preHandler', async (request, reply) => { |
| 7 | if (request.routerPath.startsWith('/admin')) { |
| 8 | const token = request.headers.authorization?.replace('Bearer ', ''); |
| 9 | if (!token || !(await verifyToken(token))) { |
| 10 | reply.code(401); |
| 11 | throw new Error('Unauthorized'); |
| 12 | } |
| 13 | } |
| 14 | }); |
| 15 | |
| 16 | app.addHook('onResponse', async (request, reply) => { |
| 17 | request.log.info({ |
| 18 | method: request.method, |
| 19 | url: request.url, |
| 20 | statusCode: reply.statusCode, |
| 21 | responseTime: reply.elapsedTime, |
| 22 | }, 'request completed'); |
| 23 | }); |
best practice
Fastify has first-class async/await support. Thrown errors route through setErrorHandler, removing the need for manual try/catch in every route.
| 1 | class AppError extends Error { |
| 2 | constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') { |
| 3 | super(message); |
| 4 | this.statusCode = statusCode; |
| 5 | this.code = code; |
| 6 | } |
| 7 | } |
| 8 | |
| 9 | app.get('/users/:id', async (request) => { |
| 10 | const user = await app.db.findUser(request.params.id); |
| 11 | if (!user) throw new AppError('User not found', 404, 'USER_NOT_FOUND'); |
| 12 | return user; |
| 13 | }); |
| 14 | |
| 15 | app.setErrorHandler((error, request, reply) => { |
| 16 | const statusCode = error.statusCode || 500; |
| 17 | if (statusCode {'>'}= 500) request.log.error({ err: error }, 'Server error'); |
| 18 | reply.code(statusCode); |
| 19 | return { |
| 20 | error: error.code || 'INTERNAL_ERROR', |
| 21 | message: statusCode === 500 ? 'Internal server error' : error.message, |
| 22 | }; |
| 23 | }); |
warning
Graceful Shutdown
Handle SIGTERM and SIGINT in production. app.close() stops accepting connections, runs onClose hooks, and closes the server.
| 1 | app.addHook('onClose', async (instance) => { |
| 2 | await instance.db.close(); |
| 3 | }); |
| 4 | |
| 5 | ['SIGTERM', 'SIGINT'].forEach((signal) => { |
| 6 | process.on(signal, async () => { |
| 7 | app.log.info({ signal }, 'Shutting down'); |
| 8 | await app.close(); |
| 9 | process.exit(0); |
| 10 | }); |
| 11 | }); |
Decorators attach properties or methods to the Fastify instance, request, or reply. They replace singleton imports and keep handlers clean.
| 1 | app.decorate('timestamp', () => new Date().toISOString()); |
| 2 | app.decorateRequest('user', null); |
| 3 | app.decorateReply('sendError', function (statusCode, message, code) { |
| 4 | this.code(statusCode); |
| 5 | return this.send({ error: code, message }); |
| 6 | }); |
| 7 | |
| 8 | app.addHook('preHandler', async (request) => { |
| 9 | request.user = await authenticate(request); |
| 10 | }); |
| 11 | |
| 12 | app.get('/me', async (request, reply) => { |
| 13 | if (!request.user) { |
| 14 | return reply.sendError(401, 'Unauthorized', 'UNAUTHORIZED'); |
| 15 | } |
| 16 | return { user: request.user, at: app.timestamp() }; |
| 17 | }); |
Fastify uses a trie-based router and schema-driven JSON serialization. In community benchmarks it typically outperforms Express by a wide margin, especially when response schemas enable fast-json-stringify.
| Framework | Throughput | Notes |
|---|---|---|
| Fastify | ~50k - 80k req/s | Trie router, compiled serializer |
| Express | ~15k - 25k req/s | Regex router, middleware stack |
| Koa | ~30k - 50k req/s | Minimal middleware |
| 1 | npm install -g autocannon |
| 2 | autocannon -c 100 -d 10 -p 10 http://localhost:3000/users |
best practice
Fastify's app.inject() simulates HTTP requests without binding to a port, perfect for fast integration tests. Combine it with Node.js 20's node:test runner and node:assert.
| 1 | import { describe, it, before, after } from 'node:test'; |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import buildApp from '../app.js'; |
| 4 | |
| 5 | describe('users routes', () => { |
| 6 | let app; |
| 7 | |
| 8 | before(async () => { |
| 9 | app = await buildApp(); |
| 10 | await app.ready(); |
| 11 | }); |
| 12 | |
| 13 | after(async () => { |
| 14 | await app.close(); |
| 15 | }); |
| 16 | |
| 17 | it('GET /users returns a list', async () => { |
| 18 | const response = await app.inject({ method: 'GET', url: '/users' }); |
| 19 | assert.equal(response.statusCode, 200); |
| 20 | const payload = response.json(); |
| 21 | assert.ok(Array.isArray(payload)); |
| 22 | }); |
| 23 | |
| 24 | it('POST /users validates required fields', async () => { |
| 25 | const response = await app.inject({ |
| 26 | method: 'POST', |
| 27 | url: '/users', |
| 28 | payload: { email: 'not-an-email' }, |
| 29 | }); |
| 30 | assert.equal(response.statusCode, 400); |
| 31 | }); |
| 32 | }); |
info
Fastify does not ship with security headers by default. Add @fastify/helmet, @fastify/cors, and @fastify/rate-limit for a solid baseline.
| 1 | import helmet from '@fastify/helmet'; |
| 2 | import cors from '@fastify/cors'; |
| 3 | import rateLimit from '@fastify/rate-limit'; |
| 4 | |
| 5 | await app.register(helmet, { |
| 6 | contentSecurityPolicy: { |
| 7 | directives: { |
| 8 | defaultSrc: ["'self'"], |
| 9 | styleSrc: ["'self'", "'unsafe-inline'"], |
| 10 | scriptSrc: ["'self'"], |
| 11 | imgSrc: ["'self'", 'data:', 'https:'], |
| 12 | }, |
| 13 | }, |
| 14 | }); |
| 15 | |
| 16 | await app.register(cors, { |
| 17 | origin: process.env.ALLOWED_ORIGIN?.split(',') || false, |
| 18 | credentials: true, |
| 19 | }); |
| 20 | |
| 21 | await app.register(rateLimit, { |
| 22 | max: 100, |
| 23 | timeWindow: '1 minute', |
| 24 | }); |
warning
Deploy Fastify with multi-stage Docker builds, non-root users, and a process manager or orchestrator that handles clustering and graceful shutdown.
| 1 | FROM node:20-alpine AS builder |
| 2 | WORKDIR /app |
| 3 | COPY package*.json ./ |
| 4 | RUN npm ci --omit=dev |
| 5 | COPY . . |
| 6 | |
| 7 | FROM node:20-alpine AS runtime |
| 8 | ENV NODE_ENV=production |
| 9 | RUN addgroup -g 1001 -S nodejs && adduser -S fastify -u 1001 |
| 10 | WORKDIR /app |
| 11 | COPY --from=builder --chown=fastify:nodejs /app . |
| 12 | USER fastify |
| 13 | EXPOSE 3000 |
| 14 | CMD ["node", "server.js"] |
| 1 | // ecosystem.config.js |
| 2 | module.exports = { |
| 3 | apps: [{ |
| 4 | name: 'fastify-api', |
| 5 | script: './server.js', |
| 6 | instances: 'max', |
| 7 | exec_mode: 'cluster', |
| 8 | env: { NODE_ENV: 'production' }, |
| 9 | max_memory_restart: '512M', |
| 10 | kill_timeout: 5000, |
| 11 | wait_ready: true, |
| 12 | }], |
| 13 | }; |
best practice
Returning the reply object
Do not return reply from an async handler. Return the payload directly or use reply.send() in a sync handler.
Ignoring encapsulation
Decorators inside a plugin are scoped to that plugin. Use fastify-plugin for globally shared decorators.
Default body limit
Fastify defaults to a 1 MiB body limit. Increase bodyLimit on specific routes or use multipart streams for large uploads.