AZ.dev

Fastify route runs after a hook already sent a reply

node: 22 fastify: 5
'Reply was already sent, did you forget to "return reply" in "/api/v1/auth/me" (GET)?'

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

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.

fastify hooks prehandler onsend sentry auth async

Was this helpful?

Related Entries

Reproduce Railway's npm ci locally with the exact npm versionSerialize per-user check-then-write flows with pg_advisory_xact_lock(hashtext(id))Content-hash dedup that restores a soft-deleted row must re-run quota and limit checksA storage URL builder and its static file handler must share one prefix constantGive a fetch call a timeout