Most Node.js APIs in production are slower than they should be, harder to test than they should be, and more tangled than they should be. Not because Node is slow - but because the framework underneath was built in 2010 and the codebase grew on top of it without ever questioning the foundation.
Fastify is the foundation a lot of people pick when they finally do question it. It's not just "Express but faster." It's a different model - schema-first, plugin-encapsulated, lifecycle-hooked, log-aware out of the box - and once you've shipped one production API on it, going back to a freeform middleware-stack framework feels like driving without lane assist.
This is a walk through what makes Fastify work, how to lay out a real project on top of it, and the small decisions that separate a demo API from one that survives a Black Friday spike.
Why Fastify is shaped the way it is
The first thing to understand about Fastify isn't the API. It's the philosophy: the framework knows the shape of your data before a request arrives.
You declare your routes with JSON Schema for the body, params, querystring, headers, and response. Fastify uses that schema for three different things at runtime:
- Validation - incoming data is checked by Ajv, one of the fastest JSON Schema validators in the JavaScript world. Ajv compiles your schema into a specialized validation function once, then reuses it. No reflection. No per-request overhead beyond a few branch predictions.
- Serialization - outgoing responses are serialized by
fast-json-stringify, which also uses your schema to generate a tailored stringify function. The standardJSON.stringifywalks every key of every object at runtime and asks the engine "what is this?" -fast-json-stringifyalready knows. - Documentation - the same schemas feed straight into
@fastify/swaggerto produce an OpenAPI spec without you writing a separate YAML file that immediately falls out of sync.
That's the whole trick. Most other Node frameworks treat schemas as a third-party concern you bolt on with joi or zod middleware. Fastify treats them as a first-class part of the route definition, and gets validation, serialization, and docs for free.
The other thing worth knowing up front: Fastify v5 requires Node.js 20 or newer. That's intentional. The framework leans on features that landed in modern V8 and modern libuv, and the team would rather drop support for old Node than ship slower code to keep it.
The performance story, with the caveats
You'll see numbers thrown around - "Fastify is 2x faster than Express", "30,000 requests per second on a laptop", and so on. These are real-ish, but they need context.
The headline benchmark is a GET / route that returns { hello: "world" }. On that test, Fastify comfortably beats Express, Koa, Hapi, and most of the rest. But you're never going to ship a { hello: "world" } endpoint. The interesting question is: where does the speed come from, and does it survive a real workload?
It comes from four places:
- Schema-based response serialization is the biggest single win. If you return a 5KB JSON object,
fast-json-stringifyis consistently several times faster thanJSON.stringifyon the same payload. Multiplied across thousands of requests per second, that's real CPU saved. - Trie-based routing. Fastify uses
find-my-way, a radix-trie router that resolves a URL in roughly constant time regardless of how many routes you've registered. Express's router walks the route list linearly per request - fine at 10 routes, painful at 500. - Avoiding hidden work in the request lifecycle. No automatic body parsing for every content type. No automatic cookie parsing unless you opt in. No regex middleware matching. Every "feature" you'd expect from Express is a plugin you explicitly add, which means you pay for nothing you don't use.
- Pino logging. The default logger is structured JSON, written via direct stream writes - no
util.formatcalls per log line, no string concatenation. We'll get to this.
Do those four things matter in your app? Honestly, sometimes no. If your route makes a 200ms database call, swapping a 0.3ms framework for a 0.05ms framework changes very little. But if you're building an internal proxy, an event ingestion endpoint, or anything CPU-bound at the framework layer, it adds up fast.
The more practical reason to use Fastify isn't raw throughput. It's that the same design that makes it fast also makes it harder to write bad code in.
Routing - schemas as the contract
Here's a route in its full form:
export default async function (fastify) {
fastify.get('/users/:id', {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' }
},
required: ['id']
},
response: {
200: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
createdAt: { type: 'string', format: 'date-time' }
},
required: ['id', 'email', 'createdAt']
},
404: {
type: 'object',
properties: {
message: { type: 'string' }
}
}
}
},
handler: async (request, reply) => {
const user = await fastify.db.users.findById(request.params.id)
if (!user) return reply.code(404).send({ message: 'User not found' })
return user
}
})
}
Read it slowly. There's no try/catch. There's no manual JSON.parse. There's no if (!isUuid(id)) return 400. There's no JSON.stringify. Every one of those is handled by the schema.
If a caller sends GET /users/not-a-uuid, Fastify rejects it with a 400 before your handler ever runs. The error response is also schema-shaped, which means your clients see consistent error payloads across every endpoint.
If your handler accidentally returns a passwordHash field, the response schema strips it. That's a real production safety feature - schemas don't just describe the API, they enforce that you don't leak fields that aren't supposed to leave the server.
Body validation and coercion
For POST/PATCH routes, the same pattern applies to the body:
fastify.post('/users', {
schema: {
body: {
type: 'object',
properties: {
email: { type: 'string', format: 'email' },
age: { type: 'integer', minimum: 13, maximum: 150 }
},
required: ['email'],
additionalProperties: false
}
},
handler: async (request, reply) => {
// request.body is fully typed and validated
return await fastify.db.users.create(request.body)
}
})
additionalProperties: false is the line that earns its keep. Without it, a client can post { email, age, isAdmin: true } and your code will happily pass isAdmin through to whatever ORM you're using. This is one of the most common assignment-injection bugs in Node apps, and Fastify's schema layer kills it in one keyword.
The Ajv default also coerces querystring values - ?limit=10 arrives as a string, but if your schema says limit: { type: 'integer' }, you get the integer. Saves you a stack of parseInt calls.
Async and async-only
Every handler is async. Whatever you return becomes the response body. Whatever you throw becomes the error. No next(), no res.end(). The framework reads the function signature and handles the rest.
If you throw an object with a statusCode, Fastify uses that status. If you throw a fastify.httpErrors.notFound('User missing') (from @fastify/sensible), you get a standard error envelope.
Plugins and the encapsulation model
This is the single concept that separates Fastify from every other Node framework, and the one that takes the longest to internalize.
In Express, everything is global. app.use(middleware) and it applies to every route registered after that line, forever, across the entire app. There's no scope. There's no isolation. The way you get separation is by spinning up a second Express app, mounting it under a sub-path, and praying.
In Fastify, every plugin is a scope. When you register() a plugin, Fastify creates a child instance with its own hooks, its own decorators, and its own routes. Changes made inside that plugin don't leak out.
import Fastify from 'fastify'
import authPlugin from './plugins/auth.js'
import publicRoutes from './modules/public/index.js'
import privateRoutes from './modules/private/index.js'
const app = Fastify({ logger: true })
await app.register(publicRoutes, { prefix: '/api' })
await app.register(async function privateContext (instance) {
await instance.register(authPlugin) // adds auth hook
await instance.register(privateRoutes)
}, { prefix: '/api' })
await app.listen({ port: 3000 })
The authPlugin adds a preHandler hook that checks JWTs. But that hook only runs for routes registered inside the privateContext scope. Public routes registered earlier are completely untouched. No "skip auth for these paths" regex. No middleware ordering bugs.
If this sounds like overkill for a small app, fair. For an app with three routes you'd never bother. But the moment you have rate limiting for one section, auth for another, a different body size limit for an upload endpoint, and a webhook endpoint that needs the raw body - the encapsulation model stops feeling academic and starts feeling like the only sane way to organize it.
The fastify-plugin escape hatch
There's one wrinkle. Sometimes you want a plugin to not be encapsulated - you want it to add a decorator (like fastify.db) that's visible to the entire app, not just its own scope.
You wrap it in fastify-plugin:
import fp from 'fastify-plugin'
import { createClient } from './my-orm.js'
async function dbPlugin (fastify) {
const client = await createClient(fastify.config.databaseUrl)
fastify.decorate('db', client)
fastify.addHook('onClose', async () => {
await client.disconnect()
})
}
export default fp(dbPlugin, { name: 'db' })
Now fastify.db is available everywhere, including in sibling plugins and child scopes. The onClose hook makes sure connections drain cleanly on shutdown, which matters more than people realize - half the "weird database errors during deploys" you'll ever see are actually unclean shutdown.
This pattern - service singletons exposed via decorators - is how you do dependency injection in Fastify. No constructor wiring, no DI container. Just decorate the instance and read it off request.server.db or fastify.db wherever you need it.

Hooks - the request lifecycle, layer by layer
Fastify exposes the request lifecycle as a sequence of named hooks. You attach functions to them, and they run in a predictable order:
onRequest → request received, before parsing
preParsing → before body is parsed
preValidation → before schema validation runs
preHandler → before your handler runs (auth lives here)
[handler]
preSerialization → before response is serialized
onSend → before payload is sent over the wire
onResponse → after response is sent
onError → only fires when an error occurs
onTimeout → only fires when a request times out
This is more granular than Express's "middleware → handler → middleware" sandwich, and the granularity is the point. Authentication belongs in preHandler (after parsing and validation, before the business logic). Adding a request-id to every log line belongs in onRequest. Stripping internal fields from the response belongs in preSerialization. Audit logging belongs in onResponse (so it doesn't block the response from being sent).
The mistake people make coming from Express is to drop everything into one giant preHandler and call it a day. You can, but you lose the visibility - Fastify's logs will tell you exactly which hook a slow request spent its time in if you keep them separated.
Logging - Pino, and why you should care
Fastify's default logger is Pino, written by the same team that maintains Fastify itself. It's structured JSON only, very fast, and intentionally minimal at the call site.
fastify.log.info({ userId, durationMs }, 'user fetched')
That produces one line of JSON, written directly to stdout (or wherever you point it). No formatting cost. No util.format string interpolation. The convention is object first, message second - the opposite of console.log, and the right way around for structured logs because the searchable fields are the object keys.
A few things to actually do in production:
Don't pretty-print in production. Pino has a
transportoption forpino-pretty- useful in dev, harmful in prod. Pretty-printing reorders JSON and breaks log shippers. Ship raw JSON to your log pipeline (Datadog, Loki, CloudWatch, whatever) and let the viewer pretty it up at read time.Set the right log level via env.
LOG_LEVEL=infofor prod,LOG_LEVEL=debugfor staging,LOG_LEVEL=traceonly when you're actively debugging - trace is loud enough to noticeably slow things down under load.Use
redactfor secrets. Pino has a built-inredactoption that nulls out fields by path before they're serialized:JavaScriptconst app = Fastify({ logger: { level: 'info', redact: ['req.headers.authorization', 'req.headers.cookie', '*.password'] } })That'll save you the day someone copies a curl from your logs and accidentally posts a Bearer token in Slack.
Use
request.log, notfastify.log, inside handlers. Fastify automatically attaches a per-request child logger with the request ID baked in. Use it - that's how you correlate all the logs from one request across hooks, services, and errors.
The thing about Pino that's actually impressive isn't the speed numbers in microbenchmarks. It's that it's so cheap to log that you stop not logging things. In an Express + Winston setup, devs end up skipping log lines because they're worried about throughput. In a Fastify + Pino setup, you just log everything, then filter at query time.
Project structure that holds up
There's no official Fastify layout, but a few patterns tend to converge after enough projects.
src/
├── server.js # builds the app, listens, handles signals
├── app.js # builds the app (no listen) - exported for tests
├── config/
│ └── index.js # loads env, validates with a schema
├── plugins/ # cross-cutting plugins (db, auth, redis, sensible)
│ ├── db.js
│ ├── auth.js
│ └── redis.js
├── modules/ # one folder per domain area
│ ├── users/
│ │ ├── index.js # registers routes + sub-plugins for this module
│ │ ├── routes.js
│ │ ├── service.js # business logic, framework-agnostic
│ │ └── repository.js # data access
│ └── orders/
│ └── ...
├── schemas/ # shared JSON Schemas (referenced by $ref)
└── lib/ # pure utilities, no Fastify imports
Two non-obvious rules that pay off:
Split app.js from server.js. app.js builds and returns a Fastify instance; server.js calls listen() and wires up signal handlers. This split costs you ten lines and gives you back integration tests that boot the entire app in memory without binding a port. Every test gets await app.inject({ method, url, payload }) and a clean teardown.
Keep business logic out of route files. A route file should validate input, call a service method, return the result. The service file is the one that does the work, and it doesn't know Fastify exists. This is the single biggest predictor of whether a Fastify codebase ages well - when business logic ends up tangled with request and reply objects, refactoring becomes a nightmare and unit tests get slow because they have to mock the framework.
Config as a plugin
A pattern worth stealing: load config once, validate it with a schema, decorate it on the app, and never read process.env anywhere else.
import fp from 'fastify-plugin'
import envSchema from 'env-schema'
const schema = {
type: 'object',
required: ['DATABASE_URL', 'JWT_SECRET'],
properties: {
NODE_ENV: { type: 'string', default: 'development' },
PORT: { type: 'integer', default: 3000 },
DATABASE_URL: { type: 'string' },
JWT_SECRET: { type: 'string', minLength: 32 },
LOG_LEVEL: { type: 'string', default: 'info' }
}
}
async function configPlugin (fastify) {
const config = envSchema({ schema, dotenv: true })
fastify.decorate('config', config)
}
export default fp(configPlugin, { name: 'config' })
envSchema validates and coerces in one step. Missing DATABASE_URL? The app refuses to start. JWT_SECRET shorter than 32 characters? Refuses to start. This catches half the "it works on my machine" failures before they reach staging.
Validation deep-dive - the parts people miss
Fastify uses Ajv 8 by default. Most users only ever touch the basics - type, required, format - but Ajv supports the full JSON Schema draft 2020-12 spec, and a few of the underused features change how you design APIs.
oneOf for polymorphic bodies. If your endpoint accepts either { type: 'card', cardToken } or { type: 'bank', accountId }, you can express that exactly:
{
"oneOf": [
{
"type": "object",
"properties": { "type": { "const": "card" }, "cardToken": { "type": "string" } },
"required": ["type", "cardToken"]
},
{
"type": "object",
"properties": { "type": { "const": "bank" }, "accountId": { "type": "string" } },
"required": ["type", "accountId"]
}
]
}
A caller who sends { type: 'card', accountId: 'x' } gets a 400. No combinator. No "if/else" in the handler.
Shared schemas via $ref and fastify.addSchema. Define your User schema once, then reference it everywhere:
fastify.addSchema({
$id: 'User',
type: 'object',
properties: { id: { type: 'string' }, email: { type: 'string' } }
})
fastify.get('/users/:id', {
schema: { response: { 200: { $ref: 'User#' } } },
handler: ...
})
One source of truth. Update the canonical schema, every route picks it up.
format: 'date-time' doesn't validate by default. This catches people. JSON Schema's format keyword is annotation-only unless you opt in. You need ajv-formats plugged in (it's the default in @fastify/ajv-compiler, but worth confirming) - otherwise format: 'email' silently does nothing.
Performance tuning, beyond "use Fastify"
Switching from Express to Fastify is the first 60% of the performance win. The next 30% comes from boring infrastructure decisions:
Keep-alive matters more than the framework. If your load balancer terminates connections per request, you'll cap out at a tiny fraction of Fastify's potential throughput because every request pays a TCP handshake. Make sure your Node server has keepAliveTimeout configured high enough to outlast your load balancer's idle timeout (typical AWS ALB is 60 seconds - set keepAliveTimeout: 65000 on the server).
undici for outbound HTTP, not node-fetch. undici is a Node.js project led by Matteo Collina, who also co-leads Fastify, and it's now the HTTP client baked into Node itself behind global.fetch. It supports HTTP/1.1 pipelining and connection pooling out of the box. If you're still using axios for every outgoing call in your routes, you're leaving easy throughput on the table.
Don't sync-read on the hot path. This sounds obvious. It isn't. The number of times a fs.readFileSync for a template or a JSON config sneaks into a request handler is high. Run @fastify/under-pressure - it'll start returning 503s when your event loop delay crosses a threshold, and that single number tells you whether something synchronous is blocking.
Set the schema response. If you remember nothing else, remember this: every route with a defined response schema is several times faster to serialize than a route without one. The default JSON.stringify is fine for small responses, but on a 50KB list response, the difference between schema-serialized and not is the difference between 5ms and 25ms of pure CPU.

Error handling without the boilerplate
Stop writing try/catch in every route. Fastify has a global error handler, and you should use it.
import fp from 'fastify-plugin'
async function errorsPlugin (fastify) {
fastify.setErrorHandler((err, request, reply) => {
request.log.error({ err, url: request.url }, 'unhandled error')
if (err.validation) {
return reply.code(400).send({
code: 'VALIDATION_ERROR',
message: err.message,
details: err.validation
})
}
if (err.statusCode && err.statusCode < 500) {
return reply.code(err.statusCode).send({
code: err.code ?? 'CLIENT_ERROR',
message: err.message
})
}
return reply.code(500).send({
code: 'INTERNAL_ERROR',
message: 'Something went wrong'
})
})
}
export default fp(errorsPlugin)
Now any throw in any route - or anywhere downstream of a route - lands here. You log once, you respond once, you don't leak stack traces to clients. Pair it with @fastify/sensible for httpErrors.badRequest() / httpErrors.notFound() helpers and your handlers become almost trivially short.
For domain errors, throw a custom class with statusCode and code properties - the global handler picks them up automatically.
Graceful shutdown - the thing tutorials skip
A production API doesn't crash when SIGTERM arrives. It drains. Most Node tutorials never show you how to do this, so here's the shape that works:
import buildApp from './app.js'
const app = await buildApp()
await app.listen({ port: app.config.PORT, host: '0.0.0.0' })
const shutdown = async (signal) => {
app.log.info({ signal }, 'shutting down')
try {
await app.close() // stops accepting connections, runs onClose hooks
process.exit(0)
} catch (err) {
app.log.error({ err }, 'error during shutdown')
process.exit(1)
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
app.close() is the magic. It stops the HTTP server from accepting new connections, lets in-flight requests finish, and then runs every onClose hook in reverse order - which is where your database disconnects, your queue consumers stop, and your Redis connection drains. Without this, your Kubernetes rolling deploys will leak a connection or two every time, and you'll spend a Thursday afternoon wondering why your DB connection count creeps up.
Pair with @fastify/under-pressure and a readiness probe that checks app.ready - and your zero-downtime deploys will actually be zero-downtime.
Testing - inject is the killer feature
The thing I miss most when I work in other Node frameworks is app.inject():
import { test } from 'node:test'
import assert from 'node:assert'
import buildApp from '../src/app.js'
test('GET /users/:id returns 404 for missing user', async () => {
const app = await buildApp({ logger: false })
const response = await app.inject({
method: 'GET',
url: '/users/00000000-0000-0000-0000-000000000000'
})
assert.equal(response.statusCode, 404)
assert.deepEqual(response.json(), { message: 'User not found' })
await app.close()
})
No port. No real HTTP. No supertest. The request is dispatched through the full framework pipeline - hooks, validation, serialization, the lot - and you get back a response object. Tests run in milliseconds. Parallelize them with the built-in Node test runner and your CI is fast.
This is also why splitting app.js from server.js pays off - buildApp() returns a ready-to-inject instance without ever binding a port.
The honest tradeoffs
Fastify is opinionated about schemas, encapsulation, and async-everything. That's a lot of opinion. A few things genuinely cost you:
The learning curve is steeper than Express. "Just add a middleware" is a five-second answer in Express. In Fastify, you have to think about which hook, which scope, and whether to wrap it in fastify-plugin. The payoff comes later.
JSON Schema is verbose. Compared to Zod or io-ts, hand-writing JSON Schema for complex objects is more typing. The standard solution is to use typebox - TypeScript-first JSON Schema definitions that you author like Zod but compile down to plain JSON Schema, so Ajv and fast-json-stringify can still use them at runtime. Best of both worlds.
The ecosystem is smaller. There's a Fastify plugin for most things you'd want, but not all. Sometimes you'll find a great Express middleware and have to wrap it with fastify-express - which works, but loses you the encapsulation guarantee for that section of the app.
Plugin ordering can bite you. Because plugins run async, registering them in the wrong order can mean a decorator isn't ready when something downstream tries to use it. The error messages have gotten much better over time, but if you're new to the framework, expect one or two "why is fastify.db undefined" debugging sessions.
None of these are dealbreakers. They're the price of admission for a framework that takes the API contract seriously and uses it to make your code both faster and safer at the same time. For an internal CRUD service that'll see a hundred requests a day, sure, use whatever. For an API that needs to handle real load, stay correct under pressure, and not collapse the day someone forgets to validate an input - Fastify earns its keep.
The shape of a production-ready Node API in 2026 looks a lot like this: schemas at the edges, business logic in the middle, hooks doing the cross-cutting work, structured logs falling out by default, and a server that drains gracefully when it's time to go. Fastify isn't the only way to get there. It's just the most direct one.






