Maximum Recursive Updates Exceeded: Vue Watcher Loops
A watcher that writes its own source re-triggers forever.
20+ years shipping production backend systems. Drawn from code that ran under real load.
- ✓How Vue watchers and computed properties track dependencies
- ✓Basic Composition API: ref, watch, and computed with setters
- ✓v-model mechanics and one-way versus two-way data flow
- Vue warns when a reactive update loop exceeds 100 iterations, usually a watcher writing its own source
- A computed with a setter that mutates its own getter dependency loops the same way
- Compare old and new values before writing, or split the input source from the derived output
- Template-side mutations (v-model plus @input handlers fighting) are a common hidden loop
- Pause the loop fast with a guard flag, then restructure so the cycle can't re-trigger
Picture two mirrors facing each other, each reflecting the other into infinity. That's a recursive update: a watcher notices a value changed, updates it in response, and that update triggers the watcher again — forever. Vue counts to 100 and then pulls the plug with this warning to save your browser tab. The way out is breaking the mirror cycle: check whether the reflection actually changed before repainting it, or point one mirror somewhere else so the loop can't form.
Your page freezes, the fan spins up, and the console fills with Maximum recursive updates exceeded. The app might still limp along, but every interaction feels like wading through mud because Vue is re-running the same reactive effects hundreds of times per tick. This warning is Vue's circuit breaker: after 100 recursive trigger rounds in one flush, it stops and tells you something is feeding back into itself.
The classic shape is a watcher that writes its own source. You watch searchQuery, and inside the handler you normalize searchQuery — which re-triggers the watcher, which normalizes again, forever. Computed setters that mutate their own getter dependencies loop identically, and template expressions that mutate state during render (calling a method that pushes to the array being rendered) create the same infinite hall of mirrors.
This article teaches you to see the cycle: how to read the warning's component trace, how to find the write that re-triggers the read, and the three structural fixes that end loops permanently. You'll learn value-compare guards for legitimate same-source writes, source/derived splitting so feedback can't form, and guard flags for migration code you can't restructure yet.
How Vue's 100-Iteration Breaker Catches Feedback Loops
Vue batches reactive updates into a flush cycle: something changes, effects re-run, the DOM updates. When an effect's re-run changes the very state that scheduled it, Vue schedules another flush — and another. To keep a bug from hanging your tab forever, Vue counts recursive trigger rounds and warns after 100 in a single flush, then stops scheduling that job. Your app survives, but the half-flushed state leaves the UI frozen or inconsistent.
This mechanism tells you two useful facts. First, the warning names the component and effect where recursion was detected, which is almost always one hop from the actual cycle — read the trace as a starting point, not gospel. Second, hitting exactly the breaker means the loop never stabilizes on its own; a loop that settles after 3 rounds (like a two-step normalization) never trips it. So the warning specifically means unbounded feedback, not just chattiness.
Common loop shapes all share one structure: a write edge that points back into its own read set. A watcher on X that assigns X. A computed setter for fullName that mutates firstName, whose getter feeds fullName. A template method formatItems() that sorts the array it's rendering, mutating reactive state during render and re-triggering the render. Once you see every case as write-meets-read, hunting the cycle becomes systematic instead of mystical.
Watchers That Write Their Own Source: the Classic Loop
The most frequent loop is embarrassingly simple: watch(search, (v) => { search.value = v.trim() }). Every assignment notifies the watcher, which trims again and assigns again. If trimming changes the string, the cycle continues until the breaker fires. Even when the value stabilizes, you've burned render cycles and triggered downstream effects for nothing.
The minimal fix is a value comparison: compute the next value, and only assign when it differs from the current one. if (cleaned !== search.value) search.value = cleaned ends the loop in one round because the second trigger sees identical values and stops writing. Vue also skips re-triggering when you assign the identical value, so compare-then-write converges fast.
The structural fix is better: don't write the source at all. Keep the raw input pristine and derive the cleaned version in a computed, or write validation results to a separate errors ref. One-way data flow — raw in, derived out — makes the cycle impossible by construction rather than by guard. Reach for the compare guard when the write is legitimate and rare; restructure to derived state when the watcher exists only to reformat its own input. Either way, re-test with the exact incident keystrokes before closing the ticket.
Computed Setter Loops and Two-Way Binding Traps
Computed properties with setters loop when the setter mutates a dependency of the getter. A fullName computed that gets from firstName + lastName but whose setter writes back into a nameParts array the getter also reads will ping-pong: set triggers get, get observes the mutation, and the cycle runs. The same happens with v-model bound to a computed whose setter normalizes into its own dependency.
Diagnose by tracing the dependency graph on paper: list everything the getter reads, then check whether the setter (or anything it calls) writes to any of those keys. If yes, that's your cycle. The fix is giving the setter its own backing store — a plain ref the getter reads but nothing else writes — so set-then-get stabilizes in one round. Alternatively, drop the computed and use explicit methods for the two directions.
Watch for the subtler variant: two computeds that derive from each other through a shared mutable object. Splitting firstName and lastName into independent refs with a pure fullName getter (no setter, or a setter writing only its own backing ref) breaks the mutual dependency. As a rule, setters should write leaf state, never state their getter derives from. Sketch the graph before coding the setter and the loop never gets written.
Template-Side Mutations That Re-Trigger Their Own Render
Templates must be pure: evaluating them should never change reactive state. But code like {{ sortedItems() }} where the method sorts the array in place mutates the very state the render depends on. The render triggers a re-render, which calls the method again, which mutates again — a loop with no watcher in sight. The same applies to v-for over a method that pushes, or :key bindings that increment a counter.
These loops confuse because the stack trace points at rendering internals rather than your handler. The tell is a method call inside interpolation or a directive expression combined with array/object mutation inside that method. Move the derivation into a computed (which caches and only recomputes when deps change) and keep template method calls side-effect free.
The v-model plus @input double-write is the sibling trap: v-model already updates the ref, and your @input handler updates it again with a transformed value, which re-renders, which fires input handling logic anew. Choose one owner for the write — either bare v-model, or :value with a single @input handler that performs the transform. One writer per ref is the invariant that keeps templates loop-free, so enforce it in every form component you review.
Guard Flags and Value Compares for Code You Can't Restructure Yet
Sometimes the loop lives in legacy code a release can't wait for. A guard flag buys safety: set a boolean before the write, bail out early when it's set, and clear it afterwards. This caps the cycle at one extra round regardless of the underlying logic. Pair it with a value comparison so identical writes never propagate even when the flag resets. It's a bandage, but a bandage that stops the freeze tonight.
Implement flags carefully to avoid freezing legitimate updates. Use try/finally so an exception can't leave the flag stuck on — a stuck flag silently swallows future updates and creates a bug far harder to find than the loop. Prefer a local flag inside the composable over a shared global, so two independent loops can't deadlock each other.
Treat every flag as tech debt with an expiry. File the follow-up that splits source from derived state, because flags mask cycles instead of removing them: the feedback edge still exists, waiting for the next author to add a write that bypasses the flag. Code review should challenge new flags with one question — why can't this be derived state? If nobody can answer, the flag ships, but the ticket stays open and gets scheduled, not shelved. Revisit flags every quarter: each surviving one is either load-bearing (document why) or removable (remove it).
Proving the Loop Is Dead: Tests That Count Trigger Rounds
A fix you can't measure is a hope, not a solution. Write tests that count effect rounds: mount the component, perform the exact interaction from the incident (type the decimal price, toggle the locale), and assert the watcher settled within a small bound. Vue Test Utils exposes watcher behavior indirectly — assert the final value and use a counter ref incremented in the handler to prove it ran once, not a hundred times.
Cover the three loop shapes in your suite: same-source watcher writes, computed setter round-trips, and template purity (mount with props and assert no state mutation warnings). For inputs, parametrize across locales and edge strings — trailing zeros, thousand separators, empty-to-value transitions — since formatting loops hide in locale-specific branches.
In production, promote the warning to a tracked error. Pipe app.config.warnHandler output for recursive-updates warnings into your error tracker with the component trace attached, and alert on first occurrence. Loops that reach users always start as warnings someone scrolled past; making them pageable ensures the next cycle gets fixed before it freezes a checkout. A warning nobody owns is a freeze waiting to happen.
The Currency Formatter That Froze Checkout for 40 Minutes
- Never write a watcher's own source unconditionally. Split raw input from derived output so formatting flows one way and the cycle structurally can't form.
- Locale-dependent formatting is a loop factory: separators and decimal rules change strings in ways that re-trigger watchers. Test numeric inputs with decimals in multiple locales.
- Read the recursion trace before blaming infrastructure. The component named in Vue's warning is the loop site — SDKs and carousels were expensive red herrings.
| File | Command / Code | Purpose |
|---|---|---|
| LoopDemo.vue | <script setup> | How Vue's 100-Iteration Breaker Catches Feedback Loops |
| SearchInput.vue | <script setup> | Watchers That Write Their Own Source |
| FullName.vue | <script setup> | Computed Setter Loops and Two-Way Binding Traps |
| ItemList.vue | <script setup> | Template-Side Mutations That Re-Trigger Their Own Render |
| PriceInput.spec.js | test('formatting settles within three rounds', async () => { | Proving the Loop Is Dead |
Key takeaways
Common mistakes to avoid
5 patternsTrimming or formatting a watched value back into itself
Sorting or filtering arrays in place inside template-called methods
slice() before sort. Never mutate reactive state during render.Binding v-model and @input transforms to the same ref
Leaving guard flags in place permanently
Ignoring the warning because the app limps along
Interview Questions on This Topic
What does Maximum recursive updates exceeded actually mean?
Frequently Asked Questions
20+ years shipping production backend systems. Drawn from code that ran under real load.
That's Vue. Mark it forged?
5 min read · try the examples if you haven't