← blog
VueOctober 19, 2026 · 15 min read

Vue 3.6: When a Watcher Throws, Your Other Watchers Stall

A Vue 3.6 reactivity regression let a throwing watcher silently stall its siblings. The exact flush() mechanism, the fix, and a copy-paste cheat sheet.

Parsa Jiravand · Frontend engineer · building bestpractic
Vue 3.6: When a Watcher Throws, Your Other Watchers Stall

Two watchers, same ref, nothing to do with each other. One of them has a bug and throws. The other one — which has no bug at all — just stops updating. Not forever. It comes back to life the next time some completely unrelated ref changes somewhere else in your app. If you've ever stared at a watcher that "randomly" seemed to miss an update and blamed your own code, this is the kind of thing that can actually cause it — and across every Vue 3.6 pre-release — from the first alpha through the last rc before the fix — it genuinely did.

By the end of this article you'll be able to:

  • Explain what a reactive "batch" is and why Vue groups effect notifications together instead of firing them one at a time
  • Read Vue's actual flush() loop and predict what happens when one effect in it throws
  • Name the exact regression that shipped in Vue 3.6.0-rc.9, the GitHub issue that caught it, and the fix that landed in rc.10
  • Tell the difference between this reactivity-level batch and the separate DOM-update queue nextTick flushes
  • Write watcher and computed code that doesn't depend on Vue isolating your failures for you

You've written ref, watch, and computed in Vue 3 with <script setup> and you know the basics of dependency tracking. This article assumes that; if read-time tracking itself — how Vue even knows which ref a computed depends on — is still fuzzy, Vue Reactivity: ref vs reactive builds that foundation first. Useful background, not required reading.

This is written against Vue 3.5.x (the current stable line on npm) and Vue 3.6.0-rc.10 (verified September 2026), the release candidate for Vue's rewritten, alien-signals-based reactivity core. The regression this article walks through was never in any stable 3.5.x release — 3.5 doesn't even run this code, since the alien-signals-based rewrite only exists on the 3.6 line. But it's not a one-RC blip either: the unprotected flush() loop shipped as-is from the very first 3.6.0-alpha.1, through every beta and every release candidate up to and including rc.9, and was only fixed in rc.10. Everything about the underlying batch-and-flush mechanism, though, is how Vue's reactivity has worked since well before 3.6; it's the error-handling around it, specifically, that was missing for most of 3.6's pre-release life.

Say you have two independent watch callbacks reading the same source — maybe one updates a UI label, the other logs to an analytics queue:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
import { ref, computed, watch } from 'vue' const user = ref({ name: 'Ava', age: 29 }) const label = computed(() => { // a bug: throws if `name` is ever missing return user.value.name.toUpperCase() }) watch(label, (v) => console.log('[ui] label →', v), { flush: 'sync' }) watch(user, (v) => console.log('[analytics] user changed →', v.age), { flush: 'sync', deep: true }) const unrelated = ref(0)

Both watchers are set to flush: 'sync' on purpose, so you can reason about the exact order things run in — no scheduler, no microtask, just "the write happens, then the watchers that depend on it run." Now trigger the bug:

JavaScript
user.value = { age: 30 } // no `name` — the computed's getter throws

On Vue 3.5, and on Vue 3.6 from rc.10 onward, you get what you'd expect: the [ui] label watcher's getter throws inside computed(), the error is reported through Vue's error handling, and the [analytics] user changed watcher — which has nothing to do with label and nothing wrong with it — still runs, because user changed and it's watching user.

On every 3.6 pre-release up to and including rc.9, that second line never appeared. Not because the watcher was broken. Because of when it ran:

JavaScript
1
2
3
4
5
6
console.clear() user.value = { age: 30 } // [nothing — both watchers were silent, including the one that should be fine] unrelated.value++ // [analytics] user changed → 30 ← shows up now, attached to the WRONG write

The analytics watcher's callback didn't fire for the write that actually triggered it. It fired later, piggybacking on a completely unrelated unrelated.value++. If unrelated never changes again, that watcher simply never runs for that update at all. From the outside this looks exactly like "the watcher missed an update" — a bug you'd go looking for in your own code, because nothing in the stack trace points at Vue's internals.

This was filed as vuejs/core#15674 and fixed by PR #15676, which shipped in 3.6.0-rc.10. To understand why a throw in one watcher could ever delay a different watcher, you need the actual mechanism underneath every ref write — which is worth knowing regardless of this bug, because it explains ordering questions you'll hit constantly.

Here's the picture most developers carry around, and it's wrong in a way that matters: "when I write ref.value = x, Vue loops over everything that depends on it and runs each one, one after another, right there." That's not what happens.

The mental model: a write doesn't call its dependents directly. It marks them dirty and pushes them into a single, shared notify buffer — one array, shared across every ref and computed in your whole app for the duration of that write. Once the write itself is done, Vue walks that buffer in a loop called flush(), calling effect.notify() on each entry in order. Only inside notify() does an individual watcher's callback, or a computed's recomputation, actually run.

That indirection is what makes a "batch" a real, inspectable thing instead of a metaphor. It's also why two unrelated watchers on two unrelated refs can end up sharing a buffer if their writes happen to be entangled by a computed in between (as label and user are above, via user.value.name) — and it's exactly the mechanism computed()'s caching rides on top of: a computed is dirtied through this same notify path, not recalculated eagerly on every write.

This is not the same queue nextTick flushes. nextTick's job is scheduling component re-renders on a microtask so the DOM updates once per tick instead of once per write. The notify buffer described here sits one layer underneath that: a component's render job is itself just one more effect that gets pushed into this same buffer and walked by this same flush() loop. A flush: 'sync' watcher skips the component scheduler entirely and runs straight out of this loop, which is why the examples above use it — it makes the ordering deterministic enough to reason about on paper.

Let's build the mechanism up from nothing, the same way Vue's reactivity package does it internally. This is a simplified model of the real algorithm in packages/reactivity/src/system.ts — not a toy metaphor, the actual control flow — small enough to read start to finish.

Stage 1 — one effect, one write. No batching needed yet:

JavaScript
1
2
3
4
5
6
function makeEffect(fn) { return { notify: () => fn() } } const e1 = makeEffect(() => console.log('effect 1 ran')) e1.notify() // effect 1 ran

Key concept: notify() is the thing that actually executes a watcher's callback or re-runs a computed's getter. Everything from here on is about when and in what order notify() gets called across several effects.

Stage 2 — a shared buffer and a flush loop. This is the real shape:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let notifyBuffer = [] let notifyIndex = 0 let notifyBufferLength = 0 function queue(effect) { notifyBuffer[notifyBufferLength++] = effect } function flush() { while (notifyIndex < notifyBufferLength) { const effect = notifyBuffer[notifyIndex] notifyBuffer[notifyIndex++] = undefined effect.notify() } notifyIndex = 0 notifyBufferLength = 0 }

Key concept: a write queues one or more effects into notifyBuffer, then calls flush() once. flush() walks the whole buffer in a plain loop — every queued effect gets its turn, in the order it was queued, synchronously, before flush() returns.

Stage 3 — introduce a throwing effect, with no protection. This is what every Vue 3.6 pre-release actually shipped, from 3.6.0-alpha.1 through 3.6.0-rc.9:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
const good1 = makeEffect(() => console.log('good watcher A ran')) const bad = makeEffect(() => { throw new Error('getter blew up') }) const good2 = makeEffect(() => console.log('good watcher B ran')) queue(good1) queue(bad) queue(good2) flush() // good watcher A ran // Uncaught Error: getter blew up // (good watcher B never ran — and notifyIndex/notifyBufferLength // were left pointing mid-buffer, non-zero)

Walk through exactly what happened: good1.notify() runs and returns normally, notifyIndex advances to 1. bad.notify() throws. Because there's no try/catch around the call, the exception propagates straight out of the while loop — skipping the two lines that reset notifyIndex and notifyBufferLength to 0. good2 was already sitting in the buffer, but the loop never reaches it. The buffer isn't empty and the index isn't reset — it's just abandoned, mid-walk, with good2 still inside it.

The next time any write anywhere calls flush() again, the while (notifyIndex < notifyBufferLength) check is still true from the leftover state, so the loop picks up right where it left off and finally calls good2.notify() — attached to whatever unrelated write happened to trigger that next flush. That's the exact bug from the opening example.

Stage 4 — the fix, isolating each notify call. This is what shipped in 3.6.0-rc.10, restoring 3.5's behavior:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function flush() { let hasError = false let error while (notifyIndex < notifyBufferLength) { const effect = notifyBuffer[notifyIndex] notifyBuffer[notifyIndex++] = undefined try { effect.notify() } catch (e) { if (!hasError) { hasError = true error = e } } } notifyIndex = 0 notifyBufferLength = 0 if (hasError) throw error }

Key concept: a single try/catch wraps each individual effect.notify() call, so a throw from one effect can't stop the loop from reaching the next one — the loop itself can no longer throw at all. That's what makes the reset lines right after the loop unconditional: there's no uncaught exception left to jump past them, so notifyIndex and notifyBufferLength always get reset to zero before the next write's flush. Only the first error that occurred is remembered, in hasError/error, and rethrown once every queued effect has had its turn.

Run the stage-3 example through this version and good2 ran prints immediately, in the same flush as the throw — and the error still surfaces to whoever triggered the write, just after everyone's had their notify.

  • Two throwing effects in the same batch. Only the first error is captured and rethrown; the second (and any later) thrower still gets its notify() call — so its side effects before the throw statement still happen — but its exception is silently discarded. If you need to know about every failure in a batch, don't rely on the single rethrown error; catch inside each effect instead (see Best Practices).
  • flush: 'sync' isn't required for this to matter. The examples use it to make ordering legible on paper, but default ('pre') and 'post' watchers are notified through this exact same buffer and loop — they're just queued by a component-level scheduler job that itself sits in the buffer, rather than running immediately. A throw anywhere in that chain goes through the same per-effect try/catch logic described above.
  • This was never a stable-release bug, but it was a long-lived pre-release one. It's worth repeating because version confusion is exactly how stale advice spreads in a framework ecosystem: 3.5.x never had it — it predates the alien-signals rewrite entirely — and it never reached a non-RC 3.6.0 release. But it wasn't confined to rc.9 either: the unprotected flush() shipped unchanged from 3.6.0-alpha.1 all the way through 3.6.0-rc.9, and was only fixed in rc.10. If you're pinned to an exact 3.6 pre-release for testing, pin to rc.10 or later.
  • app.config.errorHandler still fires for the thrown error either way — this bug was never about error reporting going missing, only about a sibling effect's execution being delayed. Don't confuse "I got an error callback" with "every watcher that should have run, ran."
  • A computed's own dirty flag isn't affected by this. The computed in the opening example (label) correctly stays dirty and will recompute (and rethrow) on its next read regardless of this bug; what the bug delayed was the other watcher, not the throwing one.

  • Don't let a risky computed or watcher getter run unguarded if a sibling's correctness depends on timing. Wrap the risky part in its own try/catch and fall back to a safe value, rather than letting the exception travel up into Vue's flush loop at all: js const label = computed(() => { try { return user.value.name.toUpperCase() } catch { return '—' } })
  • Treat watchers on the same source as independent, not ordered. Even with the fix, only the first error in a batch is rethrown — you cannot depend on "if watcher A didn't throw, I'll find out about watcher B's error the same way." Each effect's failure handling is its own responsibility.
  • Use app.config.errorHandler for centralized reporting, not as a substitute for per-watcher guards — it tells you that something threw, not which of several effects in the same batch also ran cleanly.
  • If you're testing against a 3.6 release candidate today, pin to rc.10 or newer. npm view vue dist-tags tells you the current rc tag; don't trust a cached node_modules from a few weeks ago to reflect it.

No. It came in with the alien-signals-based reactivity rewrite that's exclusive to the 3.6 line — 3.5.x doesn't run this code at all, and every 3.5.x release behaves like the fixed version described in Stage 4. Within 3.6, the bug wasn't limited to rc.9 either: the unprotected flush() shipped unchanged from 3.6.0-alpha.1 through 3.6.0-rc.9, and was fixed in 3.6.0-rc.10.

No — they're adjacent, not the same thing. nextTick schedules component re-renders on a microtask so the DOM updates once per tick. The notify buffer in this article is the layer underneath: a component's render job is itself one effect queued into this buffer, alongside your watch and computed effects. flush: 'sync' watchers skip the nextTick scheduling and run directly out of this loop.

With the fix (3.6.0-rc.10 and later, and all of 3.5), no — your render job still gets its notify() call in the same flush, exactly like good2 in Stage 4. On any unpatched pre-release (3.6.0-alpha.1 through rc.9), it theoretically could, for the same reason any other queued effect could: a throw earlier in the buffer would abandon the loop before reaching it.

No — watch() and computed() are still the right tool. The takeaway isn't "avoid the reactivity system," it's "don't assume Vue isolates failures between independent effects for you." Guard the specific logic that can realistically throw, inside the watcher or computed itself.

vuejs/core#15674 has the original repro and root-cause analysis; PR #15676 has the diff that shipped in 3.6.0-rc.10.

WhatHow it behaves
A ref writeMarks dependent effects dirty, queues them into a shared notify buffer — does not call them directly
flush()Walks the notify buffer in a loop, calling effect.notify() on each queued effect in order
flush: 'sync' watcherRuns straight out of this loop, with no nextTick scheduling in between
Default ('pre'/'post') watcherQueued through a component-level scheduler job, which is itself just one more effect in the same buffer
One effect throws (3.5, and 3.6 ≥ rc.10)Every other queued effect still runs; the first error is rethrown once the whole batch is drained
One effect throws (any 3.6.0-alpha.1 – rc.9 pre-release)The loop abandons mid-buffer; remaining effects are delivered later, attached to the next unrelated write
Two effects throw in one batchBoth still run; only the first effect's error is rethrown, the second's is discarded
app.config.errorHandlerStill fires for the thrown error in every version — this was never a reporting bug
JavaScript
1
2
3
4
5
6
7
8
9
// Defensive pattern: don't depend on Vue isolating failures between effects const safeValue = computed(() => { try { return riskyDerivation(source.value) } catch (err) { app.config.errorHandler?.(err, null, 'computed getter') return fallbackValue } })

Runs right in your browser — poke at it and watch the concept react live.

  • A ref write doesn't call its watchers directly — it queues them into a shared notify buffer, and a single flush() loop walks that buffer afterward.
  • For the entire 3.6 pre-release line, from 3.6.0-alpha.1 through 3.6.0-rc.9, a throw in one effect could abandon that loop mid-walk, silently delaying unrelated effects until the next write. Fixed in 3.6.0-rc.10 (#15674, #15676), matching how 3.5.x always behaved.
  • Even fixed, only the first error in a batch is rethrown to your code — later throwers still execute but their errors are swallowed.
  • Don't rely on Vue to isolate failures between independent watchers or computeds; guard the specific logic that can throw, inside the effect itself.
  • The nextTick DOM queue and this reactivity-level notify buffer are two different layers — your component's render job is just one more effect sitting in the same buffer as your watchers.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

A watcher that "randomly" stops firing isn't random — it's either your own logic or, if you were on a 3.6 pre-release before rc.10, a scheduling gap that's now closed. Either way, the fix is the same: stop assuming Vue hands you isolation you never asked for, and guard the part that can actually throw.

Have you hit a watcher that seemed to miss an update for no reason? Drop what you found in the comments — framework-internals bugs like this one are exactly the kind of thing worth comparing notes on.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.