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.
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
Tab B, after the flag stuck
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.
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.
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?
2. Both new tests begin with
expect(() => onmessage(...)).toThrow('boom'). What
does that assertion prove about the bug?
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.
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?
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?
finally runs after a catch completes
normally, so the second option is not how the construct behaves.