A guard that only reset when nothing went wrong

TanStack Query pull request 11242 adds try and finally around one callback. Without them, a single throw from unrelated code could stop a browser tab from broadcasting anything again.

TanStack/query PR 11242 at a631add, explained 2026-09-01

Background

New to cross-tab cache sync? Expand for the deeper background.

Open your app in two browser tabs. Each tab is a separate JavaScript program with its own memory, so each has its own copy of the query cache. Update your profile in one tab, and the other tab still shows the old name until something tells it otherwise.

The browser offers a way to talk between them. BroadcastChannel is a named channel: any tab that opens the same name receives what the others post to it. The experimental package @tanstack/query-broadcast-client-experimental uses it to keep query caches in step. Roughly:

broadcastQueryClient({ queryClient, broadcastChannel: 'my-app' })

After that call, two things are wired up. The tab subscribes to its own cache, and posts a message whenever a query changes. And it listens on the channel, applying messages other tabs post.

Put those two together and a problem appears. Tab A posts a change. Tab B receives it and writes it into its own cache. Tab B's cache subscriber notices the write and posts it, because posting on change is exactly its job. Tab A receives that, writes it, posts it. The two tabs now talk forever about one change.

The usual fix is a flag: while applying somebody else's message, do not broadcast. That is the flag this change is about, and it is named transaction.

The flag and the helper that manages it are six lines of closure state inside broadcastQueryClient. This is the code before the change:

packages/query-broadcast-client-experimental/src/index.ts:55, before

  let transaction = false
  const tx = (cb: () => void) => {
    transaction = true
    cb()
    transaction = false
  }

Set the flag, do the work, clear the flag. Two things about the surrounding code matter here.

Grepping the package for transaction returns four lines: the declaration, two writes, and one read. The read is at the top of the cache subscriber.

packages/query-broadcast-client-experimental/src/index.ts:112

  const unsubscribe = queryCache.subscribe((queryEvent) => {
    if (transaction) {
      return
    }

Below that early return are the three calls that post to other tabs, and nothing else. So the flag suppresses outbound messages only. It does not touch the local cache write, which happens either way.

The same search shows tx( appears once, in the handler for incoming messages. So both writes live inside tx, and nothing else in the package can set or clear the flag.

Key concept A flag that means "we are inside something" has to become false on every path out of that something, including the paths you did not plan. Set-do-clear reads as three steps, and it is really two branches: one where the work returns, and one where it throws.

Intuition

The bug is that a throw skips the last line. Nothing catches it, nothing resets the flag, and there is no other writer to fix it later.

The flag's effect while stuck is what makes it hard to notice. Nothing crashes, and the screen stays correct. Every local change still works in this tab. The tab stops telling anyone else, and keeps doing so for as long as the page stays open, because the flag lives in a closure that only a reload discards.

stateDiagram-v2
    state "Idle. transaction false. local changes broadcast" as Idle
    state "Applying. transaction true. local changes suppressed" as Applying
    state "Stuck. transaction true. nothing broadcasts again" as Stuck

    [*] --> Idle: tab opens
    Idle --> Applying: a message arrives from another tab
    Applying --> Idle: the callback returns
    Applying --> Stuck: the callback throws. before the fix
    Applying --> Idle: the callback throws. after the fix
    Stuck --> Stuck: every later local change

Two arrows leave Applying in the before case, and only one returns to Idle. The fix does not add a state. It makes the throw arrow point back to Idle. One thing the diagram leaves out: the error itself keeps travelling outward on that arrow, which the next section covers.

What follows from that

Follow one change through two tabs after the flag has stuck. The arrows carry what is actually posted.

Tab B, healthy

local edit
cache write
subscriber
flag false
posts to Tab A

Tab B, after the flag stuck

local edit
cache write
subscriber
flag true
returns. nothing posted

Because the guard sits in the subscriber and everything below it is posting, Tab B's own reads and writes are untouched. Tab A stops receiving updates, and nothing in either tab reports it. A reload of Tab B clears the flag, since it is closure state.

The fix

packages/query-broadcast-client-experimental/src/index.ts:56

  const tx = (cb: () => void) => {
    transaction = true
-    cb()
-    transaction = false
+    try {
+      cb()
+    } finally {
+      // Guard against `cb` throwing (e.g. `query.setState`/`queryCache.build`
+      // triggering a listener that throws while applying an incoming
+      // cross-tab message). Without this, `transaction` would stay `true`
+      // forever, silently disabling this tab's own broadcasts to other tabs
+      // for the rest of the session.
+      transaction = false
+    }
  }

Note what is absent. There is no catch. A finally block runs on the way out whether the work returned or threw, and with no catch to stop it, the original error keeps going. So the fix restores the flag without hiding the failure that broke it.

Edge case The flag is broader than the echo it needs to stop. Of the three outbound posts the guard suppresses, the added one fires unconditionally and the removed one fires when the query has observers, so both are real echoes. The updated post is conditional on the cache action being success, and query.setState dispatches an action of type setState, so that path would not have echoed even with no flag at all. The flag covers it anyway, which is the simpler rule to hold.

Code walkthrough

The flow, end to end. A message arrives from another tab. onmessage passes the whole apply-locally body to tx, which raises the flag. The body writes to the cache. The cache notifies its listeners synchronously, in the same call stack. One of those listeners throws. What happens to the flag from there is the change.

Three files: the fix, its tests, and a changeset.

1. The only caller

packages/query-broadcast-client-experimental/src/index.ts:148

  channel.onmessage = (action) => {
    if (!action?.type) {
      return
    }

    tx(() => {
      const { type, queryHash, queryKey, state } = action
      const query = queryCache.get(queryHash)

Everything after that opening, through to line 190, runs inside the callback. The handler branches on the message type and reaches one of five cache mutations: query.setState at lines 160 and 178, and queryCache.build at lines 164 and 181, with queryCache.remove at line 174. All of them are inside tx. The only code outside it is the early return for a message with no type.

2. Why the callback can throw

The callback can throw because a cache write notifies subscribers immediately, in the same stack, not on a later tick.

packages/query-core/src/queryCache.ts:200

  notify(event: QueryCacheNotifyEvent): void {
    notifyManager.batch(() => {
      this.listeners.forEach((listener) => {
        listener(event)
      })
    })
  }

queryCache.build reaches that notify, and query.setState reaches an equivalent one after synchronously calling every observer.

notifyManager.batch is the same shape as the fix, one layer down. It counts nested transactions, and it restores the count in a finally with no catch:

packages/query-core/src/notifyManager.ts:54

      let result
      transactions++
      try {
        result = callback()
      } finally {
        transactions--
        if (!transactions) {
          flush()
        }
      }

So query-core already protects its own counter this way, and lets the error continue outward. The broadcast package's flag was the one link in the chain that did not.

So any listener throwing lands in the tx callback. That includes an application's own queryCache.subscribe handler, a devtools listener, or an observer callback. None of those know they are running inside somebody else's transaction.

3. The tests, and which one tests the bug

Two tests are added, and they drive the two different mutation paths. The first posts an added message for a query hash the cache does not have, so the handler falls to queryCache.build. The second seeds a query first and posts updated for that exact hash, so the handler takes query.setState and returns before build.

Each test then asserts twice. Both snippets below come from the first test, and the two assertions do different jobs.

packages/query-broadcast-client-experimental/src/__tests__/index.test.ts:69

      expect(() => {
        lastCreatedChannel.onmessage?.({
          type: 'added',
          queryHash: '["remote"]',
          queryKey: ['remote'],
          state: { data: 1 },
        })
      }).toThrow('boom')

That one passes before the fix and after it. The old code threw out of onmessage too, so it cannot tell the two versions apart. It pins something else: that the fix did not turn the stuck flag into a swallowed error. A catch would have reset the flag too, and this assertion rules that out.

packages/query-broadcast-client-experimental/src/__tests__/index.test.ts:78

      explodingUnsubscribe()
      mockPostMessage.mockClear()

      // A later local change must still be broadcast to other tabs, instead
      // of being silently swallowed because the transaction flag got stuck.
      queryClient.setQueryData(['local'], { value: 1 })

      expect(mockPostMessage).toHaveBeenCalled()

This is the one that fails before the fix. It removes the exploding listener, clears the spy, makes an ordinary local change, and checks that the tab still posts. Under the old code the flag was still true, the subscriber returned at its first line, and postMessage was never reached again for that client.

One more detail. The local setQueryData creates a new query, which produces an added event, and that post is unconditional. So the test does not depend on the conditional updated path from the edge case above.

4. The seam the test needed

packages/query-broadcast-client-experimental/src/__tests__/index.test.ts:16

    BroadcastChannel: class MockBroadcastChannel {
      onmessage = null
      postMessage = mockPostMessage
      close = mockClose
      constructor() {
        lastCreatedChannel = this
      }

broadcastQueryClient constructs its channel internally and never hands it back; the return value is a teardown function. So the existing tests could only watch what went out, through the shared postMessage spy. Capturing this in the mock constructor gives the test the actual instance whose onmessage the production code assigned, which is how this test simulates a message arriving.

5. Why the exploding listener does not break the test setup

The test registers a subscriber that always throws. The broadcast client has a subscriber too, and it needs to keep working. Both are on the same cache.

The ordering is what saves it. broadcastQueryClient subscribes when it is called, which the tests do first, and the cache stores listeners in a Set, which iterates in insertion order. So the broadcast subscriber runs first, hits the transaction guard, returns, and only then does the exploding one throw.

6. The changeset

.changeset/broadcast-client-transaction-stuck.md

---
'@tanstack/query-broadcast-client-experimental': patch
---

fix(broadcast-client): recover from errors thrown while applying an incoming cross-tab message instead of permanently disabling this tab's own broadcasts

A patch, for the one experimental package. The changeset names recovery from the error, not prevention of it.

7. The same shape elsewhere

The pull request describes this as the same class of bug as a guard that resets only on the happy path, and says similar issues were fixed in the persister packages. That history is not checkable from a shallow single-commit checkout, so treat it as the author's account rather than something this page verified.

The pattern itself is visible in the repository, and it is what makes the shape worth naming. Five persist-client packages hold a restore flag while a promise runs, and each resets it in a .finally: the angular, react, preact, solid, and svelte persist-client packages. That matters because the restore they wrap rethrows on failure, so a .then alone would not run. It is the promise-shaped equivalent of what this change does synchronously.

One place has the same shape without it. packages/vue-query/src/vueQueryPlugin.ts:51 sets a restore flag and clears it inside the promise's .then, with no .catch or .finally. What that means for that package is a question for its maintainers. It is here because it shows how easy the shape is to write without noticing.

The transferable part Any time you write "set a flag, do work, clear the flag", the flag is an obligation to code you cannot see. Put the reset where the language guarantees it runs: finally for synchronous work, .finally for a promise. And prefer finally without catch, so the error that broke the invariant still reaches whoever should hear about it. A stuck guard is worse than a crash, because a crash tells you when and where.

Quiz

Five questions about why the change is shaped the way it is. Click an option to see the answer.

1. While transaction is stuck at true, what stops working in that tab?

The guard is the first statement of the cache subscriber, which runs after a write, and everything below it is posting. So the tab keeps working normally for the person using it, which is what makes this hard to notice. Incoming messages keep being applied too, since the flag is only read in the subscriber.

2. Both new tests begin with expect(() => onmessage(...)).toThrow('boom'). What does that assertion prove about the bug?

The old code also threw out of onmessage, so this assertion cannot distinguish the two versions. What it rules out is a catch that swallows the error, which would also have reset the flag. Only the second assertion, that a later local change still posts, fails before the change.

3. Why can the callback passed to tx throw at all? Nothing in the broadcast package raises an error there.

The listeners belong to the application, to devtools, or to observers, and none of them know they are running inside a transaction. The notify path uses try/finally with no catch of its own, so it restores its own state and passes the error along.

4. The test adds a cache subscriber that always throws, on the same cache the broadcast client subscribes to. Why does the broadcast client's own subscriber still run?

Insertion order is doing real work here, and it comes from Set semantics rather than from anything the test declares. The cache does not isolate listeners from each other, which is also why one throwing listener could reach the flag at all.

5. Suppose the fix had been try { cb() } catch {} finally { transaction = false }. The flag would reset. What breaks?

This is why the tests assert the throw. A stuck flag and a swallowed exception are both silent, so one replaces the other. The first option is worth taking seriously, since a listener that throws really can disrupt sync; what the chosen shape does is leave that listener's failure visible to whoever owns it. finally runs after a catch completes normally, so the second option is not how the construct behaves.