A preHandler or onRequest hook sends a 401, 403 or 429, but the route handler runs anyway, crashes on missing state, and Fastify logs FST_ERR_REP_ALREADY_SENT and ERR_HTTP_HEADERS_SENT.
The Fix
Return the reply from any hook that answers the request.
// async hook
fastify.addHook("preHandler", async (request, reply) => {
if (!request.headers.authorization) {
return reply.code(401).send({ error: "Authentication required" });
}
});
// callback hook: answer and stop, never call done() after send()
fastify.addHook("preHandler", (request, reply, done) => {
if (!request.headers.authorization) {
reply.code(401).send({ error: "Authentication required" });
return;
}
done();
});
If you wrap an async function in a callback hook, the inner function has to return the reply too, so done() waits for it:
type AsyncPreHandler = (request: FastifyRequest, reply: FastifyReply) => Promise<FastifyReply | undefined>;
function wrapAsync(fn: AsyncPreHandler): preHandlerHookHandler {
return (request, reply, done) => {
fn(request, reply).then(() => done(), done);
};
}
async function auth(request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply | undefined> {
const session = await verify(request);
if (!session) return reply.code(401).send({ error: "Invalid or expired token" });
request.user = session;
return undefined;
}
Why This Happens
After a hook finishes, Fastify decides whether to run the route by checking reply.sent. That getter is true only once the underlying response has ended (reply.raw.writableEnded). Calling send() doesn’t end it right away: the payload first goes through every onSend hook.
If all your onSend hooks are synchronous, the response ends before control returns to Fastify, reply.sent is true, and the route is skipped. Add a few async onSend hooks and the response is still being written when the hook resolves or calls done(). Fastify sees reply.sent === false and runs the route.
A FastifyReply is thenable: it resolves when the response finishes. Returning it from an async hook makes Fastify wait for that, so reply.sent is true by the time it checks.
Gotchas
- Passes locally, fails in production. Sentry’s Fastify integration wraps hooks and adds its own
onSendhook. Tests and dev servers without Sentry often have a short enoughonSendchain thatsend(); return;happens to work. In one app, a single asynconSendhook was fine and two more broke it. - The warning names the route, not the hook.
did you forget to "return reply" in "/api/v1/auth/me"points at the handler. The handler is the second send. The bug is in the hook that sent first. - The handler’s crash hides the real cause. You usually see a
TypeErrorfirst, for exampleCannot read properties of null (reading 'userId'), because the route ran without the session the hook would have set. - Security checks fail open. A CSRF or rate-limit hook written as
void reply.code(403).send(...)with noreturnstill lets the route make its change. The client gets the 403; the side effect still happens. - Test it with async onSend hooks registered. Add
for (let i = 0; i < 3; i++) app.addHook("onSend", async () => undefined)to the test app, then assert the route handler was never called. Asserting only the status code passes even with the bug.
Alternative Causes
The same FST_ERR_REP_ALREADY_SENT warning also appears when a route handler calls reply.send() without return and then returns a value, or sends twice in different branches. If no hook replied for that request, look at the handler first.