Repository navigation
Description
Prerequisites
- I have written a descriptive issue title
- I have searched existing issues to ensure it has not already been reported
Fastify version
5.12.5
Plugin version
No response
Node.js version
22.23.2
Operating system
Linux
Operating system version (i.e. 20.04, 11.3, 10)
Fedora (container: node:22-slim)
Description
Replying from an async lifecycle hook stops the chain as documented — unless the application has two or more async onSend hooks registered. In that case the route handler runs anyway.
The docs say:
Replying from a hook implies that the hook chain is stopped and the rest of the hooks and handlers are not executed. [...] If the hook is
async,reply.send()must be called before the function returns or the promise resolves, otherwise, the request will proceed.
In the reproduction below reply.send() is called before the async hook returns, so the chain should stop. It does for 0 or 1 onSend hooks, and does not for 2 or more.
The client still receives the body sent by the hook, so the only visible symptom is the status code, which the handler overwrites on an already-sent reply. That makes it easy to miss: we ran this in production for some time, and only noticed when one route returned 400 carrying a 401 body.
I appreciate return reply is the recommended form and fixes it. The bug is that the outcome depends on how many async onSend hooks are registered elsewhere in the app — a hook authored in one place changes the halting semantics of hooks everywhere else.
Steps to Reproduce
import Fastify from 'fastify'
async function run (onSendCount) {
const app = Fastify()
let handlerRan = false
for (let i = 0; i < onSendCount; i++) {
app.addHook('onSend', async (req, reply, payload) => payload)
}
app.get('/', {
preHandler: async (req, reply) => {
reply.code(401).send({ error: 'unauthorized' })
// reply.send() called before the async hook returns
},
handler: async (req, reply) => {
handlerRan = true
return reply.code(400).send({ error: 'handler ran' })
}
})
const res = await app.inject({ method: 'GET', url: '/' })
await app.close()
console.log(`onSend hooks=${onSendCount} handlerRan=${handlerRan} status=${res.statusCode} body=${res.body}`)
}
for (const n of [0, 1, 2, 3]) await run(n)Output on 5.12.5:
onSend hooks=0 handlerRan=false status=401 body={"error":"unauthorized"}
onSend hooks=1 handlerRan=false status=401 body={"error":"unauthorized"}
onSend hooks=2 handlerRan=true status=400 body={"error":"unauthorized"}
onSend hooks=3 handlerRan=true status=400 body={"error":"unauthorized"}
Note the body stays unauthorized while the status becomes 400.
Narrowing it down. Both conditions are required — the replying hook must be async, and there must be ≥2 async onSend hooks:
BROKEN 2 async onSend, async preHandler
ok 2 callback onSend, async preHandler
ok 1 async + 1 callback onSend, async preHandler
ok 2 async onSend, callback preHandler
ok 2 async onRequest (instead of onSend), async preHandler
ok 2 async preValidation (instead of onSend), async preHandler
So it is specific to the count of async onSend hooks; the same number of onRequest or preValidation hooks does not trigger it, and callback-style onSend hooks do not either.
It is not limited to preHandler. With 2 async onSend hooks registered, replying from any async request hook lets the handler run:
BROKEN replying from async onRequest
BROKEN replying from async preParsing
BROKEN replying from async preValidation
BROKEN replying from async preHandler
Expected Behavior
The handler should not run, and the response should be 401, regardless of how many onSend hooks are registered — as the documentation states.
If sending without return reply from an async hook is considered unsupported, I think it would help to say so unconditionally in the docs, since today it works in the common case and silently changes behaviour once a second async onSend hook is added anywhere in the application.
Activity
fix: stop the hook chain as soon as an async hook has replied
fix: stop the hook chain as soon as an async hook has replied
BhavishTrehan77 commentedon Oct 10, 2026
rokas-kve commentedon Oct 14, 2026
Some follow-up data, since I had the reproduction to hand.
I ran the repro from the issue body against every Fastify major back to 4.0.0 (Node 22.23.2, one clean install per version). Two things came out of it, and the first corrects my own report.
The halting behaviour has never differed. I described this as 5.12.5 behaviour because that is what we were running. It is identical back to 4.0.0: replying from an async
preHandlerstops the chain with zero or one asynconSendhooks registered, and does not with two or more. Not a 5.x regression.The symptom changed at 5.11.0, and that boundary is the interesting part.
ERR_HTTP_HEADERS_SENTUp to and including 5.10.0, the handler writing to an already-sent reply threw out of a promise and terminated the process:
From 5.11.0 that throw is gone, the chain still runs, and the status code is just overwritten.
So between those versions a crash became a silently wrong status code. That is the whole reason this survived in our production: the only remaining trace was one route answering
400with a401body.I am not claiming 5.11.0 introduced a bug — suppressing that throw looks like a reasonable fix on its own. It may be that whoever made it did not know the chain was still running underneath.
Happy to open a PR, but I would rather not guess which half is the intended behaviour: should two or more async
onSendhooks be able to change whether an asyncpreHandlerreply halts the chain, or is the documented halt the contract and the hook count irrelevant? Point me at the intended semantics and I will write it.rokas-kve commentedon Oct 17, 2026
My last comment ended by asking which half is intended, which was the wrong way round — it put the decision on you before anything could move. Sorry about that.
So here is something that needs no decision first: #7129 adds a single
todotest covering this boundary and nothing else.It asserts no behaviour.
hooks-async.test.jsalready tests the halt with oneonSendhook registered; the new test is the same scenario with two, which is currently uncovered — plausibly why the symptom could change across versions without anything going red. Markedtodo, so it is reported but not counted:Exits
0, lint clean, no effect on the coverage gate.Happy to follow up with the actual fix in whichever direction you decide — halt unconditionally, or document that sending without
return replyfrom an async hook is unsupported — or to close it if you would rather this wait for triage.For anyone arriving from the symptom rather than the cause: the full write-up of how this surfaced in production — a
400carrying a401body, the four reproductions that failed before one worked, and the bisect to the 5.11.0 boundary — is at https://getsnap.dev/blog/fastify-hook-halt-bug.htmlEomm commentedon Oct 24, 2026
This is an application bug to me:
fastify.addHook('preHandler', async (req, reply) => { - reply.code(401).send({ error: 'unauthorized' }) + return reply.code(401).send({ error: 'unauthorized' }) })SameRainbows commentedon Nov 1, 2026
Looked into why the halting behavior differs specifically at 2+ async onSend hooks.
preHandlerCallback in lib/handle-request.js already intends to halt when a response was sent:
\\js
function preHandlerCallback (err, request, reply) {
if (reply.sent) return
\\
However,
eply.sent is defined in lib/reply.js as:
\\js
sent: {
enumerable: true,
get () {
return (this[kReplyHijacked] || this.raw.writableEnded) === true
}
}
\\
aw.end() is called after all onSend hooks complete.
With 0 or 1 onSend hooks, microtask timing resolves the reply before preHandlerCallback executes. But with 2 or more async onSend hooks, the async preHandler promise resolves on the first microtask tick while the onSend chain is still awaiting subsequent hooks. When preHandlerCallback runs on that tick, his.raw.writableEnded is still alse, so
eply.sent evaluates to alse and the route handler is invoked.
Setting an internal flag (e.g. his[kReplySent] = true) at the start of Reply.prototype.send so
eply.sent reflects that sending has begun prevents preHandlerCallback from falling through to the route handler across any number of async onSend hooks.
visualkhh commentedon Nov 7, 2026
Hit this exact bug class in my own server and can confirm the mechanism — plus the diagnosis transfers cleanly.
I use @dooboostore/simple-boot-http-server, whose filter contract is documented the same way ("return false → do not flow further"). I wrote the two-filter version of the repro as a test and it failed identically: filter responds
401, returns false, handler still ran and its200overwrote the status while the client kept the filter's body — precisely the "400 carrying a 401 body" symptom in the report.The root cause in my case was the same shape the report points at: the chain-halt decision was inferred from ambient state (
resIsDone()at check time) instead of being carried explicitly. Sinceres.send()/resEnd()is async and may still be in flight when the hook returns,resIsDone()is momentarily false and the router proceeds. The fix that held: mark the halt on the chain state itself (ahaltedflag) and skip routing on that flag, never onresIsDone()— the latter is a response fact, the former is a control-flow fact, and conflating them is what makes the outcome depend on how many other async hooks happen to be registered.Worth pinning in the regression test both ways: (1) the handler must not run (
callsarray), not merely "status is right" — the status can pass by luck if the handler happens to preserve it; (2) run the same test with 0, 1, and 2 registered asynconSendhooks, since the count-dependence is the actual defect. My 1-filter chain passed before the fix too; only the 2-filter shape exposed it.SameRainbows commentedon Nov 10, 2026
To close the loop on why
return replyfixes this and why the count of asynconSendhooks matters:Replyimplementsthen(lib/reply.js:490), making everyReplyinstance a thenable whose completion waits foreos(this.raw)(the underlying response stream finishing).When an async hook returns
reply(or usesawait reply.send(...)), the JavaScript runtime awaitsreply.then(). The async hook's promise does not resolve until the HTTP response has ended, at which pointreply.sent(this.raw.writableEnded) is guaranteed to betrue, andpreHandlerCallbackhalts.When an async hook calls
reply.send(...)withoutreturnorawait, the async function returnsPromise.resolve(undefined)immediately. The hook runner advances on the next microtask tick:onSendhooks, the pipeline finishes quickly enough thatraw.writableEndedis set before the hook callback evaluates.onSendhooks, the chained promises inonSendrequire multiple microtask ticks. The async hook promise resolves whileonSendis still awaiting subsequent hooks, meaningthis.raw.writableEndedis stillfalse.preHandlerCallbackseesreply.sent === falseand invokes the route handler.Tested across all three styles on Node 24:
The confusion in the issue likely comes from
docs/Reference/Hooks.md:348:That wording suggests
return replyis only needed when sending from callbacks or outside the hook promise. In practice, becauseReplyis the thenable that synchronizes witheos(this.raw), an async hook must alwaysreturn replyorawait replywhen sending a response. Clarifying that section in the docs would prevent users from relying on the barereply.send()race condition.