Visitar URL original
Replying from an async hook does not halt the chain when 2+ async onSend hooks are registered · Issue #7090 · fastify/fastify · GitHub
Skip to content

Replying from an async hook does not halt the chain when 2+ async onSend hooks are registered #7090

Open

Description

@rokas-kve

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

  1. added 2 commits that reference this issue on Oct 9, 2026
    170634a
    a2f8cf2
  2. BhavishTrehan77 commented on Oct 10, 2026

    @BhavishTrehan77
  3. rokas-kve commented on Oct 14, 2026

    @rokas-kve
    Author

    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 preHandler stops the chain with zero or one async onSend hooks 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.

    fastify halts at 0–1 hooks halts at 2–3 hooks process exit
    4.0.0, 4.28.1 yes no 1 — uncaught ERR_HTTP_HEADERS_SENT
    5.0.0, 5.3.0, 5.6.0, 5.9.0, 5.10.0 yes no 1 — same throw
    5.11.0, 5.12.0, 5.12.5 yes no 0 — silent

    Up to and including 5.10.0, the handler writing to an already-sent reply threw out of a promise and terminated the process:

    Error [ERR_HTTP_HEADERS_SENT]: Cannot write headers after they are sent to the client
        at onSendEnd (/node_modules/fastify/lib/reply.js:493:7)
        at wrapOnSendEnd (/node_modules/fastify/lib/reply.js:435:5)
        at next (/node_modules/fastify/lib/hooks.js:213:7)
        at handleResolve (/node_modules/fastify/lib/hooks.js:230:5)
    

    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 400 with a 401 body.

    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 onSend hooks be able to change whether an async preHandler reply 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.

  4. rokas-kve commented on Oct 17, 2026

    @rokas-kve
    Author

    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 todo test covering this boundary and nothing else.

    It asserts no behaviour. hooks-async.test.js already tests the halt with one onSend hook 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. Marked todo, so it is reported but not counted:

    # tests 35
    # pass 34
    # fail 0
    # skipped 0
    # todo 1
    

    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 reply from 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 400 carrying a 401 body, 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.html

  5. Eomm commented on Oct 24, 2026

    @Eomm
    Member

    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' })
      })
  6. SameRainbows commented on Nov 1, 2026

    @SameRainbows

    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
    }
    }
    \\

    his.raw.writableEnded is only set once 
    

    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.

  7. visualkhh commented on Nov 7, 2026

    @visualkhh

    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 its 200 overwrote 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. Since res.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 (a halted flag) and skip routing on that flag, never on resIsDone() — 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 (calls array), 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 async onSend hooks, since the count-dependence is the actual defect. My 1-filter chain passed before the fix too; only the 2-filter shape exposed it.

  8. SameRainbows commented on Nov 10, 2026

    @SameRainbows

    To close the loop on why return reply fixes this and why the count of async onSend hooks matters:

    Reply implements then (lib/reply.js:490), making every Reply instance a thenable whose completion waits for eos(this.raw) (the underlying response stream finishing).

    When an async hook returns reply (or uses await reply.send(...)), the JavaScript runtime awaits reply.then(). The async hook's promise does not resolve until the HTTP response has ended, at which point reply.sent (this.raw.writableEnded) is guaranteed to be true, and preHandlerCallback halts.

    When an async hook calls reply.send(...) without return or await, the async function returns Promise.resolve(undefined) immediately. The hook runner advances on the next microtask tick:

    • With 0 or 1 onSend hooks, the pipeline finishes quickly enough that raw.writableEnded is set before the hook callback evaluates.
    • With 2 or more async onSend hooks, the chained promises in onSend require multiple microtask ticks. The async hook promise resolves while onSend is still awaiting subsequent hooks, meaning this.raw.writableEnded is still false. preHandlerCallback sees reply.sent === false and invokes the route handler.

    Tested across all three styles on Node 24:

    style=bare   hooks=0 handlerRan=false status=401
    style=bare   hooks=1 handlerRan=false status=401
    style=bare   hooks=2 handlerRan=true  status=400
    style=bare   hooks=3 handlerRan=true  status=400
    
    style=return hooks=0 handlerRan=false status=401
    style=return hooks=1 handlerRan=false status=401
    style=return hooks=2 handlerRan=false status=401
    style=return hooks=3 handlerRan=false status=401
    
    style=await  hooks=0 handlerRan=false status=401
    style=await  hooks=1 handlerRan=false status=401
    style=await  hooks=2 handlerRan=false status=401
    style=await  hooks=3 handlerRan=false status=401
    

    The confusion in the issue likely comes from docs/Reference/Hooks.md:348:

    "When reply.send() is called outside of the promise chain, it is important to return reply otherwise the request will be executed twice."

    That wording suggests return reply is only needed when sending from callbacks or outside the hook promise. In practice, because Reply is the thenable that synchronizes with eos(this.raw), an async hook must always return reply or await reply when sending a response. Clarifying that section in the docs would prevent users from relying on the bare reply.send() race condition.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions