fastify review
Fastify 5.12.1 is a Node.js HTTP framework whose routes can carry JSON Schema for request validation and response serialization. It compiles those schemas, exposes request-scoped Pino logging, and uses registered plugins to add routes, hooks, decorators, and services inside explicit encapsulation boundaries. This is server code: our browser build failed in esbuild. Version 5.12.1 is a security release that fixes root-primitive validation bypass and `X-Forwarded-*` spoofing when `trustProxy` uses a hop count. It also makes `preClose` run once per declaring instance and corrects canonical URLs for a prefixed slash route.
Fastify 5.12.1 fits Node APIs that will use route schemas and plugin encapsulation rather than treating the framework as a faster Express clone. Install the security release, configure proxy trust precisely, and look elsewhere for edge portability or an opinionated full-stack application structure.
We installed it
| Install | ✓ · 4.9s | 49 packages on disk · 14 MB |
| Import | ✓ | ESM import works · require() works · CommonJS package |
| Browser | n/a | could not be bundled for the browser (Node-only code, most likely) |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does fastify install cleanly?
Yes. In a fresh container with an empty cache, npm install fastify finished in 5 seconds, leaving 49 packages and 14 MB on disk. npm audit reported no known vulnerabilities.
Can fastify run in a browser?
Not directly: esbuild could not bundle it for the browser in our run, which normally means it depends on Node built-ins. Use it on the server, or find a browser-targeted alternative.
Does fastify work with both ESM and CommonJS?
Yes. Both import 'fastify' and require('fastify') worked in Node 22 in our run. The package is published as CommonJS.
Does fastify include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
fastify or express: which should you use?
express: Use it when existing middleware and team familiarity outweigh schema compilation and scoped plugins. Fastify 5.12.1 fits Node APIs that will use route schemas and plugin encapsulation rather than treating the framework as a faster Express clone.
When should you not use fastify?
The service must run unchanged in browser or edge-worker runtimes. Our esbuild browser target failed, and Fastify's server, stream, socket, and lifecycle APIs are built around Node.
Use it if
- Your Node API can define JSON Schema beside each route and benefit from compiled input checks plus response serialization.
- Plugins should own their routes, hooks, decorators, startup work, and shutdown cleanup inside a predictable scope.
- Structured request logging is required from the first endpoint; Fastify wires Pino into the request lifecycle when logging is enabled.
- Tests should exercise the full routing lifecycle in process through `app.inject()` without opening or reserving a TCP port.
- The service must run unchanged in browser or edge-worker runtimes. Our esbuild browser target failed, and Fastify's server, stream, socket, and lifecycle APIs are built around Node.
- The team expects Express middleware to register directly. Fastify has its own hooks and plugins; compatibility layers such as `@fastify/middie` add another model and do not make every middleware safe.
- Nobody will maintain route schemas. Fastify works without them, but you give up its compiled validation, controlled serialization, and much of the reason to choose it over a smaller router.
- Plugin visibility and boot order sound like needless ceremony for this service. Encapsulation is deliberate, and a decorator registered in a child scope is unavailable to its parent or siblings.
- You need a batteries-included application architecture with dependency injection, modules, an ORM choice, and project conventions. Fastify supplies the HTTP and plugin layers, leaving those decisions to the application.
Setup reality
We installed fastify 5.12.1 in a fresh Node 22 Bookworm container. npm took 4.9 seconds, put 49 packages on disk, and used 14 MB. Fastify declares 15 direct dependencies and no peers; the package is 3912 KB unpacked. npm audit found zero known vulnerabilities. It is CommonJS with no exports map, while both require() and ESM import worked. TypeScript declarations are included. Our esbuild browser target failed, which confirms this is Node-side code.
A useful Fastify application writes schemas for params, queries, bodies, headers, and replies. Request validation runs before the handler. Response schemas also filter serialized fields, so an omitted property disappears even if the handler returns it. Treat schemas as part of the API contract and test them. User-provided schemas are application code: the documentation warns against passing untrusted schema text into the validators or using database access in initial validation.
Registration is asynchronous and scoped. Await plugin registration when later setup depends on it, wrap shared plugins with fastify-plugin when their decorators must escape the child scope, and declare decorator dependencies close to the plugin. The listening default is local host. Containers normally need { host: '0.0.0.0' }, paired with deliberate network exposure. Set trustProxy only for proxies you control; 5.12.1 specifically repairs spoofing under hop-count trust.
Use one reply style per handler. An async handler can return a value, or it can send through reply; mixing the two makes lifecycle reasoning harder. Hooks have fixed phases, and premature work in the wrong hook can run before authentication or parsing. Close the instance during tests and shutdown so onClose handlers release pools. Fastify does not move CPU-heavy work off the event loop, and its speed claims do not erase database or upstream latency.
Patterns
Start a logged JSON server start-server
import Fastify from 'fastify'
const app = Fastify({ logger: true })
app.get('/', async () => ({ hello: 'world' }))
try {
await app.listen({ port: 3000 })
} catch (error) {
app.log.error(error)
process.exitCode = 1
}An async route may return an object and let Fastify serialize it. The default listener is local to the machine unless a host is supplied.
Reject an invalid JSON body validate-body
app.post('/users', {
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['name', 'email'],
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
},
},
}, async (request) => ({ created: request.body.name }))Body validation happens before the handler and normally returns 400 on failure. Register any extra AJV formats your schema uses.
Limit fields in a successful reply serialize-response
app.get('/users/:id', {
schema: {
response: {
200: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'integer' },
name: { type: 'string' },
},
},
},
},
}, async () => ({ id: 1, name: 'Ada', passwordHash: 'secret' }))The compiled response serializer emits only schema fields. That can protect a secret, but it also hides an accidentally undocumented field, so assert the public shape in tests.
Type params and query values type-route-input
type RequestShape = {
Params: { id: string }
Querystring: { verbose?: boolean }
}
app.get<RequestShape>('/items/:id', {
schema: {
params: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } },
querystring: { type: 'object', properties: { verbose: { type: 'boolean' } } },
},
}, async (request) => ({ id: request.params.id, verbose: request.query.verbose ?? false }))A TypeScript generic does not validate network input. Keep the runtime schema aligned, or use a supported type provider that derives types from the same schema.
Expose a database decorator share-plugin-decorator
import fp from 'fastify-plugin'
async function databasePlugin (app, options) {
const db = await connect(options.url)
app.decorate('db', db)
app.addHook('onClose', async () => db.close())
}
export default fp(databasePlugin, { name: 'database' })
await app.register(import('./database-plugin.js'), { url: process.env.DB_URL })A normal registered plugin creates a child scope. The `fastify-plugin` wrapper makes this decorator available to the surrounding application and records plugin metadata.
Protect only one route group scope-auth-hook
await app.register(async function protectedRoutes (scope) {
scope.addHook('onRequest', async (request, reply) => {
if (!await verify(request.headers.authorization)) {
return reply.code(401).send({ error: 'unauthorized' })
}
})
scope.get('/admin', async () => ({ allowed: true }))
}, { prefix: '/api' })The hook belongs to this plugin scope and its descendants. Routes registered outside the scope do not inherit it.
Map validation and server failures handle-errors
app.setErrorHandler((error, request, reply) => {
request.log.error({ err: error })
if (error.validation) {
return reply.code(400).send({ error: 'invalid request' })
}
const status = error.statusCode && error.statusCode < 500 ? error.statusCode : 500
return reply.code(status).send({ error: status === 500 ? 'internal error' : error.message })
})Validation errors reach the custom handler too. Avoid returning internal exception text on 5xx responses even though the full error belongs in server logs.
Exercise a route without a socket test-with-inject
import test from 'node:test'
import assert from 'node:assert/strict'
import { build } from './app.js'
test('health route', async (t) => {
const app = build()
t.after(() => app.close())
const response = await app.inject({ method: 'GET', url: '/health' })
assert.equal(response.statusCode, 200)
assert.deepEqual(response.json(), { ok: true })
})Injection waits for application boot and runs the request lifecycle in process. Closing the instance also tests plugin shutdown paths.
Bind a container-visible address listen-in-container
await app.listen({
port: Number(process.env.PORT ?? 3000),
host: '0.0.0.0',
})Binding all interfaces is normally needed inside a container. Restrict published ports and place authentication or a trusted proxy in front of any non-public service.
Allow one browser origin configure-cors
import cors from '@fastify/cors'
await app.register(cors, {
origin: ['https://app.example.com'],
methods: ['GET', 'POST'],
credentials: true,
})Use a `@fastify/cors` version compatible with Fastify 5. Avoid reflecting arbitrary origins when credentials are enabled.
Add a text content parser parse-custom-content
app.addContentTypeParser('text/plain', { parseAs: 'string', bodyLimit: 64 * 1024 },
function parseText (request, body, done) {
done(null, body)
}
)
app.post('/events', async (request) => ({ length: request.body.length }))Set a body limit that matches the endpoint. Content parsers belong to an encapsulation scope, so register the parser where the consuming routes can see it.
Run plugin cleanup on termination shut-down-cleanly
async function shutdown (signal) {
app.log.info({ signal }, 'shutting down')
try {
await app.close()
} catch (error) {
app.log.error(error)
process.exitCode = 1
}
}
process.once('SIGTERM', () => shutdown('SIGTERM'))
process.once('SIGINT', () => shutdown('SIGINT'))`close()` stops accepting work and runs registered shutdown hooks. The process can exit naturally after open pools and handles have been released.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| express | npm | Use it when existing middleware and team familiarity outweigh schema compilation and scoped plugins. |
| koa | npm | Use it when a small async middleware core is enough and you want to choose routing, validation, and logging separately. |
| hono | npm | Use it when the same handler model must target Node, Bun, Deno, and worker-style edge platforms. |
More web backend guides
urllib3 · requests · ws · anyio · undici · httpx · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

