Guide
Fastify fundamentals explained
Fastify is a schema-first, low-overhead HTTP framework for Node.js that prioritizes throughput and predictable request handling. Where Express leaves validation, serialization, and logging entirely to middleware you bolt on, Fastify bakes JSON Schema validation, compiled serializers, structured logging, and a plugin encapsulation model into the core. Teams pick Fastify when they need a conventional Node server (not an edge Worker) with strong typing, fast JSON responses, and a clear lifecycle for hooks and decorators. The tradeoffs are real: the plugin model has a learning curve, some Express middleware does not translate cleanly, and ultra-light edge stacks may still prefer Hono. This guide covers Fastify routing and schemas, plugins and encapsulation, hooks, error handling, TypeScript inference with TypeScript, testing and deployment, a Harbor Payments webhook API worked example, a framework decision table, common pitfalls, and a production checklist.
What Fastify is
Fastify is built around three ideas that differentiate it from minimal frameworks:
- Schema-driven I/O — declare JSON Schema for
body,querystring,params, andresponse; Fastify validates inbound data and serializes outbound JSON with compiled functions instead of genericJSON.stringifyon every request. - Plugin encapsulation — features register as async plugins with their own scope; decorators and hooks do not leak across plugin boundaries unless you explicitly break encapsulation.
- Structured logging —
pinois the default logger; every request gets a child logger with a generated request id suitable for distributed tracing.
Fastify runs on Node.js (and can be adapted to other runtimes via community adapters). It is not an ORM, not a job queue, and not a replacement for PostgreSQL connection pooling — you still wire database clients inside route handlers or services. What it gives you is a fast, opinionated HTTP layer that scales from a single webhook receiver to multi-route APIs behind a load balancer.
Creating a server and defining routes
A minimal Fastify app looks like this:
import Fastify from 'fastify'
const app = Fastify({
logger: true,
requestIdHeader: 'x-request-id',
})
app.get('/health', async () => ({ status: 'ok' }))
await app.listen({ port: 3000, host: '0.0.0.0' })
Routes are declared with HTTP verbs (get, post,
put, delete, patch). Handlers can be
sync or async; returned objects are serialized according to the route schema.
Prefix groups use register with a plugin function:
async function paymentsRoutes(app) {
app.post('/webhooks/stripe', { schema: stripeWebhookSchema }, handleStripe)
}
await app.register(paymentsRoutes, { prefix: '/api/v1' })
JSON Schema on routes
Schemas are the Fastify superpower. A POST handler with validation:
const createInvoiceSchema = {
body: {
type: 'object',
required: ['customerId', 'amountCents', 'currency'],
properties: {
customerId: { type: 'string', format: 'uuid' },
amountCents: { type: 'integer', minimum: 1 },
currency: { type: 'string', enum: ['USD', 'EUR', 'SOL'] },
},
additionalProperties: false,
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
status: { type: 'string' },
},
},
},
}
app.post('/invoices', { schema: createInvoiceSchema }, async (req, reply) => {
const invoice = await billing.create(req.body)
return reply.code(201).send(invoice)
})
Invalid bodies return 400 automatically with a structured error
payload — no manual if (!body.amount) guards in every handler.
Pair schemas with
Zod via
fastify-type-provider-zod if your team prefers Zod syntax over raw
JSON Schema, but understand Fastify still compiles to JSON Schema under the hood.
Plugins and encapsulation
Almost everything in Fastify is a plugin registered with
app.register(plugin, options). Plugins can be nested; each level
creates a new encapsulation context. Decorators added inside a plugin (for example
app.decorate('billing', billingService)) are visible to that plugin
and its children, not to sibling plugins.
Common official plugins from the @fastify/* ecosystem:
- @fastify/cors — configurable CORS headers for browser clients.
- @fastify/helmet — security-oriented HTTP headers.
- @fastify/rate-limit — per-IP or per-route throttling.
- @fastify/jwt — sign and verify JSON Web Tokens.
- @fastify/postgres or @fastify/mongodb — attach pooled clients as decorators.
- @fastify/swagger + @fastify/swagger-ui — OpenAPI docs generated from route schemas.
Plugins must be async-aware: export an async function and await any
setup (database connections, config loading). Fastify will not listen until the
plugin graph finishes loading. Use fastify-plugin (fp) wrapper when
a plugin must break encapsulation and register decorators on the parent scope
— typical for shared auth or database pools used across route modules.
Hooks lifecycle
Hooks are functions that run at defined points in the request lifecycle. Understanding the order prevents subtle bugs:
- onRequest — first touch; assign trace ids, reject oversized payloads early.
- preParsing — transform raw body stream before parsing.
- preValidation — run after body parse, before schema validation.
- preHandler — after validation; ideal for auth checks that need validated params.
- preSerialization — mutate payload before response schema serialization.
- onSend — last chance to alter serialized payload or headers.
- onResponse — request complete; record latency metrics.
- onError — centralized error logging and formatting.
A webhook signature verifier belongs in preHandler after the raw body
is available but before business logic runs. Global rate limiting often sits in
onRequest. Avoid heavy database work in onRequest —
it runs for every route including health checks unless you scope hooks with
fastify.addHook('preHandler', fn) inside a plugin instead of on the root instance.
Error handling and logging
Fastify normalizes errors through setErrorHandler:
app.setErrorHandler((err, req, reply) => {
const status = err.statusCode ?? 500
req.log.error({ err, status }, 'request failed')
reply.status(status).send({
error: err.code ?? 'internal_error',
message: status < 500 ? err.message : 'Internal server error',
requestId: req.id,
})
})
Throw app.httpErrors.badRequest('invalid_currency') from
@fastify/sensible for standard HTTP errors with correct status codes.
Validation errors from AJV include validation arrays — map them
to user-friendly field messages in production APIs.
Structured logs via Pino integrate cleanly with log aggregators (Loki, Datadog,
CloudWatch). Pass req.log into service layers so every billing or
webhook event shares the same request id. Disable pretty-printing in production;
use pino-pretty only in local development.
TypeScript and testing
Fastify ships first-class TypeScript support. With
fastify-type-provider-zod or JSON Schema type providers, handler
req.body types infer from the route schema — no duplicate
interface definitions. Enable strict in tsconfig.json and
compile with tsc or run via tsx in development.
Testing uses app.inject() to simulate HTTP without binding a port:
const res = await app.inject({
method: 'POST',
url: '/api/v1/invoices',
payload: { customerId: '...', amountCents: 5000, currency: 'USD' },
})
expect(res.statusCode).toBe(201)
expect(res.json()).toMatchObject({ status: 'pending' })
Build the app in a factory (buildApp()) that registers plugins but
does not call listen — tests import the factory, inject requests,
and optionally use app.close() in teardown. For integration tests
against a real database, spin up the app once per suite with test containers
and run migrations before the first test file.
Production deployment patterns
Fastify listens on a single Node process by default. In production:
- Run behind a reverse proxy (nginx, Caddy, cloud load balancer) that terminates TLS.
- Use Node cluster mode or multiple containers with a shared-nothing design; avoid in-memory session state unless sticky sessions are configured.
- Set
trustProxy: truewhen readingX-Forwarded-Forfor rate limits or audit logs. - Wire graceful shutdown: on SIGTERM, call
await app.close()to drain in-flight requests before exit. - Expose
/healthand/readyendpoints; readiness should check database connectivity, not just process uptime.
Fastify’s performance advantage matters most on JSON-heavy APIs with high QPS. For mostly static file serving or SSR, the framework choice matters less than caching and CDN configuration.
Worked example: Harbor Payments webhook API
Harbor Payments processes Stripe and on-chain settlement events through a dedicated webhook service. The Fastify layout:
- Root app — registers
@fastify/helmet,@fastify/rate-limit(100 req/min per IP on webhook paths), and a globalonRequesthook assigningx-request-idif missing. - Database plugin —
fastify-pluginwraps apgpool decorated asapp.db, shared across route plugins. - Stripe plugin — scoped under
/webhooks/stripe;preHandlerverifies HMAC signature against raw body; schema accepts only knownevent.typeenums. - Idempotency — handler inserts
stripe_event_idinto a dedup table inside a transaction; duplicate deliveries return200without re-processing. - SOL settlement plugin — sibling plugin under
/webhooks/solana; validates JSON body against a stricter schema (signature, slot, amount lamports).
Invoice creation for merchants lives in a separate /api/v1 plugin with
JWT auth via @fastify/jwt in preHandler. OpenAPI docs
generate from the same JSON Schema definitions that enforce runtime validation
— no drift between docs and behavior. CI runs app.inject() tests
for every webhook fixture plus one integration test against a disposable Postgres
container. Deployments roll through Kubernetes with readiness probing
/ready until the pool connects successfully.
Framework decision table
| Choose Fastify when… | Prefer Express when… | Prefer Hono when… |
|---|---|---|
| JSON Schema validation is a first-class requirement | Team already standardizes on Express middleware ecosystem | Target is Cloudflare Workers or edge runtimes |
| Throughput and low JSON serialization overhead matter | Prototyping speed beats schema rigor | Bundle size and Web Standards Request/Response are priorities |
| Plugin encapsulation fits modular monolith APIs | Legacy codebase migration cost is high | Same codebase must run on Deno, Bun, and Workers |
| Pino structured logging is desired out of the box | Minimal learning curve for junior hires | JSX server rendering on edge is central |
| OpenAPI from schemas is part of API contract workflow | Socket.io or unusual middleware stacks dominate | Node-only features (native pg pools) are not needed |
Common pitfalls
- Breaking encapsulation accidentally — decorators registered inside a route plugin are invisible to siblings; wrap shared deps with
fastify-plugin. - Validating without response schemas — inbound validation alone still allows handlers to return shapes clients cannot parse; define
responseschemas on public routes. - Double body parsing — do not add Express
body-parsermiddleware; Fastify owns the stream. For webhooks needing raw bodies, useaddContentTypeParser. - Blocking the event loop — Fastify is fast, but synchronous CPU work (large PDF generation, unbounded
JSON.parseon megabyte payloads) stalls all requests on the process. - Ignoring
await app.close()— hot reload and test leaks leave open handles; always close the instance in teardown. - Schema drift from Zod — if using Zod providers, regenerate or test schemas when Zod definitions change; types alone do not update runtime validation.
- Trust proxy misconfiguration — rate limits and IP audits break when
trustProxyis wrong behind load balancers. - Mixing callback and async handlers incorrectly — use async functions or call
done()explicitly; do not mix patterns on the same route.
Practitioner checklist
- Scaffold with
fastify-clior abuildApp()factory that separates app creation fromlisten. - Define JSON Schema (or Zod via type provider) for every public route’s body, params, and response.
- Register security plugins: helmet, cors (restricted origins), rate-limit on auth and webhook paths.
- Wrap shared database pools and config in
fastify-pluginmodules. - Implement
setErrorHandlermapping domain errors to HTTP status without leaking stacks. - Use
req.logchild loggers in services; includerequestIdin error JSON. - Write tests with
app.inject(); cover validation failures and auth rejection paths. - Generate OpenAPI from schemas if external integrators depend on your API contract.
- Configure graceful shutdown and readiness probes before production traffic.
- Benchmark with realistic JSON payloads if migrating from Express — measure, do not assume.
Key takeaways
- Fastify is a schema-first Node HTTP framework optimized for JSON APIs and structured logging.
- Plugins encapsulate features; use
fastify-pluginwhen decorators must be global. - Hooks provide a explicit request lifecycle — place auth and signature verification in
preHandler. - Testing via
inject()avoids port binding and speeds CI feedback loops. - Framework fit — Fastify excels on Node JSON services; edge and minimal bundles may favor Hono instead.
Related reading
- Express fundamentals explained — minimal middleware-chained Node APIs
- Hono fundamentals explained — edge-first Web Standards framework
- Zod fundamentals explained — validation schemas paired with Fastify type providers
- Node.js fundamentals explained — event loop, modules, and production Node patterns