Fastify Essentials
Fastify Essentials Fastify is a Node.js web framework built for low overhead and high throughput, without giving up the ergonomics of a batteries-included frame…
Fastify Essentials
Fastify is a Node.js web framework built for low overhead and high throughput, without giving up the ergonomics of a batteries-included framework. Its two defining features are schema-based validation/serialization (JSON Schema compiled ahead of time for very fast request parsing and response serialization) and a plugin architecture with strict encapsulation — plugins get their own scope for decorators, hooks, and routes unless they explicitly opt into sharing.
It's a good fit when you want Express-like productivity but need the throughput headroom Express's middleware chain doesn't give you for free, or when you want request/response shapes enforced by schema rather than by convention.
Server setup and route schemas
Every route can declare a JSON Schema for its params, querystring, body, and response. Fastify compiles these schemas into fast validation/serialization functions once at startup — this is where most of its performance edge over unschematized frameworks comes from, and it also means malformed input never reaches your handler.
import Fastify from 'fastify'
const app = Fastify({ logger: true })
const userSchema = {
type: 'object',
properties: {
id: { type: 'integer' },
email: { type: 'string', format: 'email' },
name: { type: 'string' },
},
}
app.route({
method: 'POST',
url: '/users',
schema: {
body: {
type: 'object',
required: ['email', 'name'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 1, maxLength: 100 },
},
},
response: {
201: userSchema,
},
},
handler: async (request, reply) => {
const user = await createUser(request.body)
reply.code(201)
return user // Fastify serializes this against the 201 response schema
},
})
app.get('/users/:id', {
schema: {
params: {
type: 'object',
properties: { id: { type: 'integer' } },
},
response: { 200: userSchema },
},
}, async (request, reply) => {
const user = await getUser(request.params.id)
if (!user) return reply.code(404).send({ error: 'Not found' })
return user
})
await app.listen({ port: 3000, host: '0.0.0.0' })Notice handlers are async functions that just return a value or call reply.send() — no next() to remember to call, and returning a rejected promise / throwing is automatically turned into a 500 (or a custom error via reply.code().send()) by Fastify's error handling.
Plugins and encapsulation
Everything in Fastify is a plugin — routes, decorators, and hooks are registered through `app.register()`. Plugins are encapsulated by default: a decorator or hook added inside a plugin is only visible to that plugin and anything registered after it within the same scope, not to the whole app. This is deliberate — it stops a database connection decorator meant for one feature module from silently leaking into every route in the app.
// plugins/db.js
import fp from 'fastify-plugin'
import { Pool } from 'pg'
async function dbPlugin(app, opts) {
const pool = new Pool({ connectionString: opts.connectionString })
app.decorate('db', pool)
app.addHook('onClose', async (instance) => {
await instance.db.end()
})
}
// fastify-plugin breaks encapsulation on purpose: without it, `app.db`
// would only be visible inside this plugin's own scope, not app-wide.
export default fp(dbPlugin)
// routes/users.js — a normal encapsulated plugin, scoped to /users
async function userRoutes(app, opts) {
app.addHook('preHandler', async (request, reply) => {
// only applies to routes registered in THIS plugin's scope
if (!request.headers.authorization) {
return reply.code(401).send({ error: 'Missing authorization' })
}
})
app.get('/', async (request) => {
const { rows } = await app.db.query('SELECT id, email, name FROM users LIMIT 50')
return rows
})
}
// app.js
import dbPlugin from './plugins/db.js'
import userRoutes from './routes/users.js'
app.register(dbPlugin, { connectionString: process.env.DATABASE_URL })
app.register(userRoutes, { prefix: '/users' })The `prefix` option namespaces every route inside a plugin, which is the standard way to organize a Fastify app by feature/domain instead of one flat router file.
Hooks, error handling, and the request lifecycle
Fastify's lifecycle hooks (onRequest, preParsing, preValidation, preHandler, preSerialization, onSend, onResponse, onError) let you tap into request processing at precise points — preHandler for auth checks (schema validation has already run), onSend for response header mutation right before it goes out, onError for centralized logging.
app.setErrorHandler((error, request, reply) => {
request.log.error({ err: error, url: request.url }, 'request failed')
if (error.validation) {
// Schema validation failures land here automatically
return reply.code(400).send({
error: 'validation_error',
details: error.validation,
})
}
if (error.statusCode) {
return reply.code(error.statusCode).send({ error: error.message })
}
reply.code(500).send({ error: 'internal_server_error' })
})
app.addHook('onRequest', async (request, reply) => {
request.startTime = Date.now()
})
app.addHook('onResponse', async (request, reply) => {
request.log.info({
method: request.method,
url: request.url,
statusCode: reply.statusCode,
durationMs: Date.now() - request.startTime,
})
})Because schema validation failures already produce a structured `error.validation` object, you rarely need to hand-write "is this field present" checks in handlers — that's the point of pushing validation into the schema layer instead of the request lifecycle.
Common pitfalls and gotchas
Always `await app.register(...)` (or await the returned promise chain) before calling `app.listen()` in complex setups — plugins register asynchronously, and routes registered after `listen()` in the wrong order can silently 404.
Forgetting `fastify-plugin` around a plugin that needs to decorate the top-level app is the most common encapsulation surprise — without it, `app.db` or a custom decorator only exists inside that plugin's own scope, and every sibling route file gets "decorator not found".
A response schema is a serialization contract, not just documentation — properties not listed in the schema are silently stripped from the JSON response. Add a field to your handler's return value and forget to add it to the schema, and it just vanishes from the client's response.
Fastify handlers should be async or return a promise/value — avoid mixing the older `(request, reply, done)` callback style with `async` in the same handler; picking one is what keeps error propagation predictable.
Logging is built in via Pino (`request.log`) — reach for `app.log`/`request.log` instead of `console.log` so log lines get request-scoped context (request id) and structured JSON output for free.
Route-level schemas are compiled once at startup — dynamically generating a different schema per request is not how Fastify is meant to be used and defeats the precompilation performance benefit entirely.