Symptom
Pages load fine for users (HTTP 200, full body), but the server log fills with:
level 40 Reply was already sent, did you forget to "return reply" in the "/page" (GET) route?
level 50 Cannot write headers after they are sent to the client
level 40 Reply was already sent, did you forget to "return reply" in "/page" (GET)?
request completed statusCode=500
Access metrics and error-rate dashboards report 500s for requests that clients received as 200. In one measurement on our stack, ten requests produced twelve such errors. It started right after adding an onSend hook (in our case one that sets security headers and a CSP nonce on every response).
When it happens
All of:
- An async
onSend hook that awaits at least once before returning (await of anything — even await null — or real I/O). - An async route handler (or async
preHandler) that calls reply.send(...) without returning reply or the payload, so the function resolves to undefined:
app.get('/page', async (req, reply) => { reply.type('text/html').send(html) }) // no return
The same happens in an auth preHandler that does reply.code(401).send(...) and falls through.
Reproduction matrix, fastify 5.12.5, same route without return:
| onSend hook | inject() | real HTTP |
|---|
async, no await inside | clean | clean |
async, one await | client 200, logged 200 then 500 + errors | client 200, logged 500 + errors |
async, awaits setImmediate | client 200, logged 500 + errors | client 200, logged 500 + errors |
callback (req, reply, payload, done) | clean | clean |
So a hook that happens not to await looks fine in tests; the first time someone adds an await inside it, every page without return reply starts logging 500s.
Cause
reply.send() runs the onSend hooks; with an async hook that awaits, the actual write is deferred to a later tick, so right after send() the response is neither ended nor has headers sent. The async handler then resolves with undefined. Fastify's promise wrapper sees "handler resolved, payload undefined, reply not sent yet" and calls reply.send(undefined) itself — a second send. When the first send finishes, the second one collides: "Reply was already sent", "Cannot write headers after they are sent", and the request is logged with an error status although the client already has the 200 body.
Fix
Write onSend hooks in callback style and call done synchronously:
app.addHook('onSend', (request, reply, payload, done) => {
reply.header('x-content-type-options', 'nosniff')
// compute the nonce synchronously (crypto.randomBytes(16).toString('base64') is sync)
done(null, payload)
})
Also (belt and braces) return the reply from async handlers that call send():
app.get('/page', async (req, reply) => {
return reply.type('text/html').send(html)
})
Either change alone removed the errors in the reproduction (callback hook with non-returning handlers; or async awaiting hook with return reply). We changed the hooks, because there were many routes and every new one would have to remember return.
Verify
Hit a page and a 401 route, then check the log has no double sends:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/page # 200
docker logs --since "$SINCE" api 2>&1 | grep -c 'Reply was already sent' # 0
(SINCE=$(date -u +%FT%TZ) taken just before the requests, so older log lines are not counted.) In a unit test, collect the pino log lines and assert none has level >= 40 and every res.statusCode equals what the client got.
Notes
inject() can under-report: with a hook that awaits, inject logged both a 200 and a 500 line; over real HTTP only 500s were logged. Test hooks through a real listener at least once.- Returning
payload vs. mutating it in the async hook made no difference; the await is what matters. - Only 5.12.5 was tested on 2026-09-30; the behaviour follows from how Fastify 5 wraps async handlers, but other 5.x minors were not re-run here.
- The 401 case (async auth
preHandler that sends and falls through) is from our production record; the table above covers the page-handler case.
The full body — free, open to anyone, no key.
Source: Diagnosed and fixed in WITAN's own API (2026-09) after a security-headers hook was added; reproduced on 2026-09-30 with fastify 5.12.5 on Node 22.23.3 over real HTTP and with inject().