I've been building a publishing tool , and I chose Fastify for the backend. The reasoning was simple: official plugins (jwt, cookie, cors) cover all the essentials, the performance reputation is solid, and TypeScript support is complete. Now that the P0 end-to-end flow is running, here are the pitfalls I hit along the way.
1. The second argument to register is not what you think
The most subtle pitfall. I wanted to protect a whole group of business routes with authentication, so I naturally wrote:
await app.register(connectionRoutes, { preHandler: [app.authenticate] })
The result: every endpoint was wide open — unauthenticated requests got through. Digging into the docs revealed that the second options object of register is passed as an argument to the plugin function, not treated as route config. The preHandler simply never gets applied.
The correct approach is to attach the hook inside the plugin:
export async function connectionRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate)
// ...
}
addHook follows Fastify's encapsulation model and only affects routes registered within that plugin — which is exactly the "authenticate this whole group" behavior I wanted.
2. The request keeps running after reply.send()
The second pitfall is more dangerous. My first version of the auth decorator looked like this:
try {
await req.jwtVerify()
} catch {
reply.code(401).send({ error: 'Unauthorized' })
}
After reply.send(), the request chain does not stop. The route handler still executes. The client receives a 401, but the business logic inside the handler has already run — for write operations, that's an incident waiting to happen.
The fix: throw directly inside the decorator and let setErrorHandler handle it centrally. Once an error is thrown in Fastify, the remaining hooks and the handler are skipped — which is the behavior you'd intuitively expect.
3. setErrorHandler is the perfect funnel
Together with pitfall #2, I centralized all error handling in a single setErrorHandler: custom HttpErrors pass their status code through, errors thrown by jwtVerify get a unified message, and unknown errors get logged and return a 500. Route handlers now only ever do throw new HttpError(404, 'Post not found') — no more try/catch noise.
One type-related note: @fastify/jwt requires manual module augmentation (declaring the FastifyJWT interface). A few lines of declare module give req.user proper types — an easy page to miss in the docs.
Takeaways
Fastify is quick to pick up and genuinely fast, but its plugin encapsulation model is a different mental model from Express-style "global middleware". Once you understand the scope boundaries, the first two pitfalls never happen. If your next Node project needs a high-performance API with a first-class TypeScript experience, Fastify is an easy recommendation.
Top comments (0)