|$ curl https://forge-ai.dev/api/markdown?path=docs/nodejs/fastify
$cat docs/fastify.md
updated Recently·20-28 min read·published

Fastify

Node.jsFastifyBackendIntermediate🎯Free Tools
Introduction

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.

Getting Started

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.

install.sh
Bash
1npm init -y
2npm install fastify
3npm install @fastify/cors @fastify/helmet @fastify/env
4node server.js

A minimal server configures Pino logging and registers routes; async handlers automatically serialize returned values and send a 200 response.

server.js
JavaScript
1import Fastify from 'fastify';
2
3const app = Fastify({
4 logger: { level: process.env.LOG_LEVEL || 'info' },
5});
6
7app.get('/', async () => ({ hello: 'world' }));
8app.get('/health', async () => ({ status: 'ok', uptime: process.uptime() }));
9
10try {
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

Always specify the host option in app.listen(). The default changed across Fastify versions, and omitting it can leave the server unreachable inside containers or on IPv6-only hosts.
Routes

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().

routes.js
JavaScript
1app.get('/users', async () => [{ id: 1, name: 'Ada' }]);
2
3app.post('/users', async (request, reply) => {
4 const user = request.body;
5 reply.code(201);
6 return { id: crypto.randomUUID(), ...user };
7});
8
9app.route({
10 method: 'GET',
11 url: '/users/:id',
12 handler: async (request) => {
13 return { id: request.params.id, name: 'Ada' };
14 },
15});
query-params.js
JavaScript
1app.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});
JSON Schema Validation

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.

validation-schema.js
JavaScript
1const 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
30app.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

Set additionalProperties: false on request schemas in production. This prevents unexpected fields and forces explicit schema evolution when your API changes.

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.

validation-errors.js
JavaScript
1app.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 & Encapsulation

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.

plugins.js
JavaScript
1// plugins/users.js
2async 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
7export default userRoutes;
8
9// server.js
10import userRoutes from './plugins/users.js';
11await app.register(userRoutes, { prefix: '/users' });

warning

Encapsulation is powerful but confusing for Express developers. Use fastify-plugin when a plugin must expose decorators globally, such as a database connection or authentication utility.
fastify-plugin.js
JavaScript
1import fp from 'fastify-plugin';
2
3async 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
9export default fp(dbPlugin, { name: 'db-plugin' });
10
11// app.db is now available everywhere
12app.get('/posts', async () => app.db.query('SELECT * FROM posts LIMIT 10'));
Hooks

Hooks intercept the request lifecycle. Common hooks include onRequest, preValidation, preHandler, preSerialization, onSend, and onResponse.

HookWhen It RunsCommon Use
onRequestBefore parsingRequest IDs, IP filtering
preHandlerAfter validationAuth, authorization
onSendBefore response is sentHeaders, compression
onResponseResponse sentMetrics, access logs
hooks.js
JavaScript
1app.addHook('onRequest', async (request, reply) => {
2 request.id = crypto.randomUUID();
3 reply.header('x-request-id', request.id);
4});
5
6app.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
16app.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

Use onResponse for metrics and logging because the response has actually been transmitted. At onSend the data may still fail on the wire.
Async/Await & Error Handling

Fastify has first-class async/await support. Thrown errors route through setErrorHandler, removing the need for manual try/catch in every route.

error-handling.js
JavaScript
1class 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
9app.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
15app.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

Never leak raw stack traces or database errors to clients for 500-class failures. Log the full error server-side and return a generic message to the client.

Graceful Shutdown

Handle SIGTERM and SIGINT in production. app.close() stops accepting connections, runs onClose hooks, and closes the server.

graceful-shutdown.js
JavaScript
1app.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

Decorators attach properties or methods to the Fastify instance, request, or reply. They replace singleton imports and keep handlers clean.

decorators.js
JavaScript
1app.decorate('timestamp', () => new Date().toISOString());
2app.decorateRequest('user', null);
3app.decorateReply('sendError', function (statusCode, message, code) {
4 this.code(statusCode);
5 return this.send({ error: code, message });
6});
7
8app.addHook('preHandler', async (request) => {
9 request.user = await authenticate(request);
10});
11
12app.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});
Performance & Benchmarks

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.

FrameworkThroughputNotes
Fastify~50k - 80k req/sTrie router, compiled serializer
Express~15k - 25k req/sRegex router, middleware stack
Koa~30k - 50k req/sMinimal middleware
benchmark.sh
Bash
1npm install -g autocannon
2autocannon -c 100 -d 10 -p 10 http://localhost:3000/users

best practice

Always define response schemas for hot endpoints. Compiled serialization validates shape and is much faster than generic JSON serialization.
Testing

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.

users.test.js
JavaScript
1import { describe, it, before, after } from 'node:test';
2import assert from 'node:assert/strict';
3import buildApp from '../app.js';
4
5describe('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

Build your app inside a factory function (buildApp()) so tests can create isolated instances with separate databases or mocked dependencies. Avoid singletons; they make parallel tests flaky.
Security

Fastify does not ship with security headers by default. Add @fastify/helmet, @fastify/cors, and @fastify/rate-limit for a solid baseline.

security.js
JavaScript
1import helmet from '@fastify/helmet';
2import cors from '@fastify/cors';
3import rateLimit from '@fastify/rate-limit';
4
5await 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
16await app.register(cors, {
17 origin: process.env.ALLOWED_ORIGIN?.split(',') || false,
18 credentials: true,
19});
20
21await app.register(rateLimit, {
22 max: 100,
23 timeWindow: '1 minute',
24});

warning

Never trust client input. Use parameterized queries or an ORM to prevent injection, validate file uploads, and keep secrets in environment variables parsed with Zod or @fastify/env.
Deployment

Deploy Fastify with multi-stage Docker builds, non-root users, and a process manager or orchestrator that handles clustering and graceful shutdown.

Dockerfile
Dockerfile
1FROM node:20-alpine AS builder
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci --omit=dev
5COPY . .
6
7FROM node:20-alpine AS runtime
8ENV NODE_ENV=production
9RUN addgroup -g 1001 -S nodejs && adduser -S fastify -u 1001
10WORKDIR /app
11COPY --from=builder --chown=fastify:nodejs /app .
12USER fastify
13EXPOSE 3000
14CMD ["node", "server.js"]
ecosystem.config.js
JavaScript
1// ecosystem.config.js
2module.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

In Kubernetes, run one Fastify process per pod and let Kubernetes handle clustering. Use livenessProbe and readinessProbe endpoints for health checks.
Common Mistakes

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.

$Blueprint — Engineering Documentation·Section ID: NODE-FASTIFY-01·Revision: 1.0