cheat sheet
Fastify
Fastify is a high-performance Node.js web framework with schema-driven validation, plugin encapsulation, hooks, decorators, and built-in Pino logging — covering routes, plugins, hooks, error handling, and production hardening.
Fastify — Node Web Framework
What it is
Fastify is a Node.js web framework focused on low overhead and developer experience, maintained by an OpenJS-Foundation-backed team and originally created by Matteo Collina (Node core TSC member). It's written in TypeScript-friendly JavaScript and gets its speed from three things: a Radix tree router, schema-driven JSON serialization (~2× faster than JSON.stringify), and a deliberately small middleware chain. Reach for it when you'd otherwise use Express but want validation, structured logging, and stronger plugin isolation out of the box; the closest peer is Hono (smaller, edge-first) and Express remains the legacy default.
Install
Fastify is a single npm package. The CLI scaffolder fastify-cli is optional but handy for new projects.
# Add to an existing project
npm install fastify
# Scaffold a new project
npm create fastify@latest -- --lang=ts my-api
Output:
added 1 package in 2s
Hello world
The minimum viable server: import fastify, register a route, listen. Fastify's listen() returns a Promise that resolves once the socket is bound — await it and the process keeps running because the HTTP server holds the event loop open.
import Fastify from 'fastify';
const app = Fastify({ logger: true });
app.get('/', async () => ({ hello: 'world' }));
await app.listen({ port: 3000, host: '0.0.0.0' });
Output:
{"level":30,"time":1716567890123,"pid":12345,"hostname":"myhost","msg":"Server listening at http://0.0.0.0:3000"}
Test it:
curl http://localhost:3000/
Output:
{"hello":"world"}
Routes
A Fastify route is defined by app.<method>(path, [opts,] handler) or the more explicit app.route({ method, url, handler, schema, ... }). Handlers can be async (return the body) or synchronous (call reply.send(...)). Async handlers are preferred — exceptions are caught and turned into 500 responses automatically.
import Fastify from 'fastify';
const app = Fastify({ logger: true });
// Shorthand
app.get('/users/:id', async (request, reply) => {
return { id: request.params.id };
});
// Explicit route object
app.route({
method: 'POST',
url: '/users',
handler: async (request) => {
return { created: request.body };
},
});
// Multiple methods on one URL
app.route({
method: ['GET', 'HEAD'],
url: '/health',
handler: async () => ({ ok: true }),
});
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
Inside a handler, four objects matter:
| Object | Purpose |
|---|---|
request.params | Path parameters (/:id) |
request.query | Parsed query string |
request.body | Parsed JSON / form body |
request.headers | Lower-cased header names |
reply.code(n) | Set status code |
reply.header(k, v) | Set a response header |
reply.send(payload) | Send a body (only needed in non-async handlers) |
Schemas: validation and serialization
The single feature that makes Fastify stand out: every route can declare a JSON Schema for its body, querystring, params, headers, and response. Fastify uses it to validate incoming requests (rejecting bad input with a structured 400) and to fast-stringify outgoing responses with fast-json-stringify, which is ~2× faster than JSON.stringify.
import Fastify from 'fastify';
const app = Fastify({ logger: true });
app.post('/users', {
schema: {
body: {
type: 'object',
required: ['email', 'name'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 1, maxLength: 80 },
age: { type: 'integer', minimum: 0, maximum: 120 },
},
additionalProperties: false,
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' },
},
},
},
},
handler: async (request, reply) => {
const user = { id: crypto.randomUUID(), ...request.body };
reply.code(201);
return user;
},
});
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
Send a bad payload — Fastify rejects with a JSON 400 before your handler runs:
curl -s -X POST http://localhost:3000/users \
-H 'content-type: application/json' \
-d '{"email":"not-an-email"}'
Output:
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property 'name'"}
Send a good payload:
curl -s -X POST http://localhost:3000/users \
-H 'content-type: application/json' \
-d '{"email":"alice@example.com","name":"Alice Dev"}'
Output:
{"id":"d4f1c8e0-1a2b-4c3d-9e8f-0b1c2d3e4f5a","email":"alice@example.com","name":"Alice Dev"}
The response schema is not just for OpenAPI — it filters out extra properties (no accidental leaks) and serializes faster. Defining it is genuinely free performance.
Schemas with TypeBox or Zod
Writing JSON Schema by hand is verbose. Two common companions generate it from a TypeScript-friendly DSL: TypeBox (designed for Fastify, produces JSON Schema directly) and Zod via fastify-type-provider-zod.
import Fastify from 'fastify';
import { Type } from '@sinclair/typebox';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>();
const Body = Type.Object({
email: Type.String({ format: 'email' }),
name: Type.String({ minLength: 1, maxLength: 80 }),
});
app.post('/users', {
schema: { body: Body },
handler: async (request) => {
// request.body is typed: { email: string; name: string }
return { ok: true, email: request.body.email };
},
});
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
Both DSLs give you a single source of truth: one schema definition produces a runtime validator, a serialization template, and TypeScript types via inference.
Plugins and encapsulation
A Fastify plugin is just an async function that receives the app instance. Plugins create an encapsulation context: hooks, decorators, and child plugins registered inside one plugin do not leak to siblings. This is the structural difference vs Express: in Fastify, you can mount two copies of the same plugin at different prefixes and they don't interfere.
import Fastify from 'fastify';
async function usersRoutes(app) {
app.get('/', async () => ['alice', 'bob']);
app.get('/:id', async (req) => ({ id: req.params.id }));
}
async function postsRoutes(app) {
app.get('/', async () => [{ id: 1, title: 'hello' }]);
}
const app = Fastify();
await app.register(usersRoutes, { prefix: '/users' });
await app.register(postsRoutes, { prefix: '/posts' });
await app.listen({ port: 3000 });
console.log('routes:', app.printRoutes());
Output:
routes: └── /
├── users (GET)
│ └── /:id (GET)
└── posts (GET)
Plugins can also be packaged as npm modules — that's how the entire @fastify/* ecosystem (CORS, helmet, JWT, multipart, rate-limit, sensible) works. Each is a register-able plugin.
Hooks: the request lifecycle
Hooks are functions that run at a specific phase of a request, the application boot sequence, or a response. They're how you implement auth, audit logs, response transforms, and graceful shutdown. Hooks respect encapsulation — registering preHandler inside a plugin only fires for routes in that plugin.
| Hook | Fires |
|---|---|
onRequest | Earliest — before body parsing |
preParsing | Before request body is parsed |
preValidation | After parsing, before schema validation |
preHandler | After validation, before the route handler |
preSerialization | Before response is serialized |
onSend | After serialization, before bytes are written to socket |
onResponse | After response is fully sent |
onTimeout | When the request times out |
onError | When the route handler or a hook throws |
onReady | Once, after all plugins loaded, before listen() resolves |
onClose | Once, when the server is shutting down |
import Fastify from 'fastify';
const app = Fastify({ logger: true });
// Add a request ID to every request
app.addHook('onRequest', async (request) => {
request.id = crypto.randomUUID();
request.log.info({ reqId: request.id }, 'incoming');
});
// Auth gate — runs on every route in this scope
app.addHook('preHandler', async (request, reply) => {
const token = request.headers.authorization?.split(' ')[1];
if (!token) {
reply.code(401);
throw new Error('missing token');
}
});
app.get('/me', async (request) => ({ id: request.id }));
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
Decorators
app.decorate, app.decorateRequest, and app.decorateReply attach properties to the framework, request, or reply objects. Decorating once at boot is faster than attaching properties per-request because the engine can optimise the object shape.
import Fastify from 'fastify';
const app = Fastify();
// Add an app-level helper
app.decorate('greet', (name) => `Hello, ${name}!`);
// Add a per-request helper (initialise to a default value)
app.decorateRequest('user', null);
app.addHook('preHandler', async (request) => {
request.user = { id: '42', name: 'Alice Dev' };
});
app.get('/hello', async (request) => ({
msg: app.greet(request.user.name),
}));
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
Test:
curl -s http://localhost:3000/hello
Output:
{"msg":"Hello, Alice Dev!"}
Error handling
Throwing inside an async handler (or any hook) produces a 500 with a JSON body. Customise it with setErrorHandler. Use app.httpErrors (via @fastify/sensible) or throw plain errors with a statusCode property to control the response code.
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
const app = Fastify({ logger: true });
await app.register(sensible);
app.get('/users/:id', async (request) => {
if (request.params.id === '404') {
throw app.httpErrors.notFound('user not found');
}
if (request.params.id === 'bad') {
throw app.httpErrors.badRequest('id must be numeric');
}
return { id: request.params.id };
});
app.setErrorHandler(async (error, request, reply) => {
request.log.error({ err: error, reqId: request.id }, 'request failed');
const statusCode = error.statusCode ?? 500;
reply.code(statusCode);
return {
error: error.name,
message: statusCode >= 500 ? 'internal error' : error.message,
};
});
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
curl -s http://localhost:3000/users/404
Output:
{"error":"NotFoundError","message":"user not found"}
Logging with Pino
Fastify ships with Pino, the fastest JSON logger in the Node ecosystem. Every request and reply automatically gets a child logger tagged with reqId, req.method, and req.url. Use pino-pretty in development for human-readable output; ship raw JSON in production for downstream parsing.
import Fastify from 'fastify';
const app = Fastify({
logger: {
level: 'info',
transport: process.env.NODE_ENV === 'production'
? undefined
: { target: 'pino-pretty', options: { colorize: true } },
serializers: {
req: (req) => ({ method: req.method, url: req.url, id: req.id }),
},
},
});
app.get('/', async (request) => {
request.log.info({ user: 'alicedev' }, 'rendering home');
return { ok: true };
});
await app.listen({ port: 3000 });
Output:
[10:00:00.123] INFO: Server listening at http://127.0.0.1:3000
[10:00:01.456] INFO (reqId=abc-123): rendering home
user: "alicedev"
req: { method: "GET", url: "/" }
To log to a file or external collector, set logger.stream or pipe Pino through pino-transport workers.
Production hardening with plugins
The Fastify org maintains an opinionated set of plugins for security, rate-limiting, CORS, and metrics. Compose them at boot and you have a defensible API in a few lines.
import Fastify from 'fastify';
import helmet from '@fastify/helmet';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import compress from '@fastify/compress';
import sensible from '@fastify/sensible';
const app = Fastify({ logger: true, trustProxy: true });
await app.register(helmet);
await app.register(cors, { origin: ['https://example.com'] });
await app.register(rateLimit, { max: 100, timeWindow: '1 minute' });
await app.register(compress);
await app.register(sensible);
app.get('/health', async () => ({ ok: true }));
await app.listen({ port: 3000, host: '0.0.0.0' });
Output:
{"level":30,"msg":"Server listening at http://0.0.0.0:3000"}
| Plugin | Why |
|---|---|
@fastify/helmet | Security headers (X-Frame-Options, Strict-Transport-Security, etc.) |
@fastify/cors | CORS preflight + headers |
@fastify/rate-limit | Per-IP request limiting; pluggable Redis store |
@fastify/compress | gzip / br response compression |
@fastify/sensible | app.httpErrors.*, asserts, response shortcuts |
@fastify/jwt | JWT signing / verification |
@fastify/multipart | multipart/form-data uploads |
@fastify/swagger + @fastify/swagger-ui | OpenAPI generated from schemas |
Graceful shutdown
For long-running deploys (containers, systemd) the server should drain in-flight requests on SIGTERM instead of dropping them. app.close() runs onClose hooks and stops accepting new connections.
import Fastify from 'fastify';
const app = Fastify({ logger: true });
app.get('/work', async () => {
await new Promise((r) => setTimeout(r, 2000));
return { done: true };
});
await app.listen({ port: 3000 });
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, async () => {
app.log.info(`received ${signal}, shutting down`);
await app.close();
process.exit(0);
});
}
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
Fastify vs Express vs Hono
| Feature | Fastify | Express | Hono |
|---|---|---|---|
| Runtime | Node | Node | Node, Bun, Deno, Workers, edge |
| Router | Radix tree | Linear regex | Trie (TrieRouter / SmartRouter) |
| Built-in validation | JSON Schema | None | Zod / Valibot / TypeBox via middleware |
| Built-in logger | Pino | None (morgan add-on) | None |
| Plugin isolation | Yes (encapsulation) | No | Limited |
| TypeScript types | First-class | Community @types | First-class |
| Throughput (req/s, local) | ~40k–80k | ~10k–15k | ~30k–60k |
| Ecosystem size | Medium, official orgs | Largest | Growing, edge-focused |
Pick Fastify for Node-first APIs that want validation and structure out of the box. Pick Hono if you target the edge (Cloudflare Workers, Vercel Edge) or want one framework that runs everywhere. Pick Express only when an existing ecosystem dependency requires it.
Testing with inject()
Fastify includes an in-process HTTP injector via light-my-request — perfect for unit tests because there's no listening socket, no port allocation, and no async teardown.
import { describe, it, expect } from 'vitest';
import Fastify from 'fastify';
function build() {
const app = Fastify();
app.get('/health', async () => ({ ok: true }));
return app;
}
describe('health endpoint', () => {
it('returns ok', async () => {
const app = build();
const response = await app.inject({ method: 'GET', url: '/health' });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ok: true });
await app.close();
});
});
Output:
✓ health endpoint > returns ok (5 ms)
Test Files 1 passed (1)
Tests 1 passed (1)
Common pitfalls
- Forgetting
await app.register(...)— Plugin registration is async; without the await, your hooks and decorators may not be ready whenlisten()resolves. - Using
reply.send()in an async handler — In async handlers, justreturnthe payload. Callingreply.send()and also returning a value triggers a "double-send" warning. - Not declaring a response schema — Without it, you lose ~2× serialization speed and leak any fields you accidentally include in the returned object.
- Shared mutable state across requests — Decorating the app with a mutable object means every request sees the same instance. Use
decorateRequestfor per-request state. - Async hooks that don't await — A hook that fires
await db.query(...)withoutawaitreturns a resolved Promise and Fastify moves on before the work finishes. Always await async work in hooks. addContentTypeParserfor binary — JSON is parsed by default, butapplication/octet-streamand similar require a custom parser, otherwiserequest.bodyisundefined.- Listening on
127.0.0.1in Docker — The default host binds loopback; inside a container that's invisible to the host. Usehost: '0.0.0.0'. - Plugins registered in the wrong order — Encapsulation means a plugin only sees what was registered before it, in the same scope. CORS or auth registered after a route won't apply to that route.
- Not calling
app.close()in tests — Forgetting to close leaks the server between tests, eventually exhausting file descriptors in long suites. - Mixing CommonJS and ESM Fastify —
import Fastify from 'fastify'is fine in ESM; in CJS,const Fastify = require('fastify'). The wrong combination yieldsFastify is not a function.
Real-world recipes
Typed REST API with auto-generated OpenAPI
Wire @fastify/swagger + @fastify/swagger-ui to your schemas and you get an interactive API docs page at /docs for free — no separate spec file to keep in sync.
import Fastify from 'fastify';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';
const app = Fastify({ logger: true });
await app.register(swagger, {
openapi: {
info: { title: 'Users API', version: '1.0.0' },
},
});
await app.register(swaggerUi, { routePrefix: '/docs' });
app.get('/users/:id', {
schema: {
description: 'Fetch one user',
tags: ['users'],
params: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
},
},
},
},
handler: async (request) => ({
id: request.params.id,
email: 'alice@example.com',
}),
});
await app.listen({ port: 3000 });
console.log('docs at http://localhost:3000/docs');
Output:
docs at http://localhost:3000/docs
JWT-protected routes
Use @fastify/jwt to sign tokens at login and verify them on protected endpoints. Decorating with an authenticate helper keeps each route declaration tidy.
import Fastify from 'fastify';
import jwt from '@fastify/jwt';
const app = Fastify({ logger: true });
await app.register(jwt, { secret: process.env.JWT_SECRET ?? 'dev-only' });
app.decorate('authenticate', async (request, reply) => {
try {
await request.jwtVerify();
} catch {
reply.code(401).send({ error: 'unauthorized' });
}
});
app.post('/login', async (request) => {
const token = app.jwt.sign({ sub: 'alicedev', role: 'admin' });
return { token };
});
app.get('/profile', { onRequest: [app.authenticate] }, async (request) => {
return { user: request.user };
});
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
Multipart file upload to disk
@fastify/multipart handles multipart/form-data; piping the file stream straight to disk avoids buffering large uploads in memory.
import Fastify from 'fastify';
import multipart from '@fastify/multipart';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { join } from 'node:path';
const app = Fastify({ logger: true });
await app.register(multipart, {
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
});
app.post('/upload', async (request) => {
const data = await request.file();
const dest = join('./uploads', data.filename);
await pipeline(data.file, createWriteStream(dest));
return { stored: dest, mimetype: data.mimetype };
});
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
curl -F 'file=@photo.jpg' http://localhost:3000/upload
Output:
{"stored":"uploads/photo.jpg","mimetype":"image/jpeg"}
Plugin that opens a database connection
A plugin is the right shape for any resource with a lifecycle: open at boot, close on shutdown. The onClose hook ensures the connection is released cleanly.
import fp from 'fastify-plugin';
import postgres from 'postgres';
export default fp(async (app) => {
const sql = postgres(process.env.DATABASE_URL);
app.decorate('db', sql);
app.addHook('onClose', async () => {
await sql.end();
});
});
// server.js
import Fastify from 'fastify';
import dbPlugin from './db.js';
const app = Fastify({ logger: true });
await app.register(dbPlugin);
app.get('/users/:id', async (request) => {
const rows = await app.db`SELECT id, email FROM users WHERE id = ${request.params.id}`;
return rows[0] ?? { error: 'not found' };
});
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
fastify-plugin (fp(...)) opts the plugin out of encapsulation — the decorator is visible to the entire app, not just inside the registered scope. Use it for shared resources; omit it for self-contained route plugins.
Streaming a large response
Fastify accepts a Readable as a reply body and pipes it for you with backpressure. Use this to stream a multi-gigabyte export without loading it into memory.
import Fastify from 'fastify';
import { createReadStream } from 'node:fs';
const app = Fastify({ logger: true });
app.get('/export.csv', async (request, reply) => {
reply.type('text/csv');
reply.header('Content-Disposition', 'attachment; filename="export.csv"');
return createReadStream('./exports/big.csv');
});
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
Server-sent events for live updates
A long-lived response with text/event-stream and reply.raw for direct writes — perfect for live dashboards or LLM token streaming. Hold the connection until the client disconnects.
import Fastify from 'fastify';
const app = Fastify({ logger: true });
app.get('/events', async (request, reply) => {
reply.raw.setHeader('Content-Type', 'text/event-stream');
reply.raw.setHeader('Cache-Control', 'no-cache');
reply.raw.setHeader('Connection', 'keep-alive');
const interval = setInterval(() => {
reply.raw.write(`data: ${JSON.stringify({ ts: Date.now() })}\n\n`);
}, 1000);
request.raw.on('close', () => {
clearInterval(interval);
request.log.info('client disconnected');
});
});
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}
curl -N http://localhost:3000/events
Output:
data: {"ts":1716567890000}
data: {"ts":1716567891000}
data: {"ts":1716567892000}
Background work with the request scope
Long work that shouldn't block the response: kick it off, return immediately, and let the lifecycle hook log when it finishes. Use a queue (BullMQ, Cloudflare Queues, SQS) for anything that needs durability.
import Fastify from 'fastify';
const app = Fastify({ logger: true });
async function generateReport(reportId, log) {
await new Promise((r) => setTimeout(r, 3000));
log.info({ reportId }, 'report ready');
}
app.post('/reports', async (request, reply) => {
const reportId = crypto.randomUUID();
// Detached — does not block the response; errors are logged
generateReport(reportId, request.log).catch((err) =>
request.log.error({ err, reportId }, 'report failed')
);
reply.code(202);
return { reportId, status: 'queued' };
});
await app.listen({ port: 3000 });
Output:
{"level":30,"msg":"Server listening at http://127.0.0.1:3000"}