A Vue 3 app in development throws 'Maximum recursive updates exceeded'; what has the scheduler detected, and how do you track down the cause?
answer
- a job keeps re-queuing itself
- counted per flush in dev builds
- the message names the component
- make the write converge
basics
~20 sVue 3's development scheduler counts each job's runs per flush and throws past 100: an effect keeps mutating state it depends on and re-queues itself. Find that write in the render, a hook or a watcher and make it converge.
solid answer
~50 sThe development build tracks, per flush, how many times each queued job runs. When one runs more than 100 times it throws 'Maximum recursive updates exceeded', naming the component when the job belongs to one: an effect is mutating its own dependencies and triggering itself. Only component updates and watch callbacks are allowed to re-queue themselves, so the usual sources are the ones the message lists — a template or render function that writes state, an `onUpdated` hook that writes rendered state, or watchers that feed each other. To find it, read the component name, add `onRenderTriggered` to see which dependency keeps re-triggering the render, and look for unconditional writes. Fix it by deriving with `computed`, guarding writes so they converge, or moving the write out of the render path. Production builds have no such check, so the same bug can hang the page.
code
vue · 19 lines<script setup lang="ts">
import { ref, onUpdated, onRenderTriggered } from 'vue'
const renders = ref(0)
// BUG: every update writes rendered state -> another update -> ...
onUpdated(() => {
renders.value++
})
// dev-only: log what re-triggers the render
onRenderTriggered(e => {
console.log('render triggered by', e.key, e.type)
})
</script>
<template>
<p>Rendered {{ renders }} times</p>
</template>go deeper
Recognise the message as an effect that keeps changing what it depends on, and look for code that writes state while rendering or in onUpdated.
Explain that the scheduler counts runs per job per flush in development, that only component updates and watch callbacks may re-queue themselves, and why a converging write stops it.
Diagnose it quickly with the component name, onRenderTriggered and onTrigger, and fix it structurally with computed or a single source of truth rather than deferring writes.
Treat recursion errors as release blockers since production has no guard, and push the team toward pure templates and derived state that make these loops impossible.
## What the scheduler counts Vue 3's scheduler runs queued jobs in a flush: component update jobs, pre-flush watcher jobs, then post-flush callbacks such as `onUpdated` hooks. Normally each job runs once per flush. Some jobs are allowed to **re-queue themselves** while running — component update jobs (during the render itself) and watcher callbacks — because legitimate code sometimes changes state that settles after one more pass. That allowance makes infinite loops possible, so the **development build** keeps a count per job for the duration of a flush. When a job's count goes above the recursion limit of 100, the scheduler raises: > Maximum recursive updates exceeded in component <Name>. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function. The component name appears when the job belongs to a component. The error is thrown out of the flush in development, which breaks the loop. The production build compiles this check out entirely — there the same bug can spin until the tab freezes, which is why the development error is worth treating as a real defect. ## The usual culprits | Source | Loop | |---|---| | Template or render function that writes state | render reads `x`, writes `x`, the write re-queues the render | | `onUpdated` that writes rendered state | update → hook writes → update → hook writes … | | Two watchers feeding each other | `watch(a, () => b.value++)` and `watch(b, () => a.value++)` | | A watch callback that always writes its own source | callback writes the source with a new value every time | The common thread: a write that **never converges** — every pass produces a value different from the last, so the next pass is triggered again. ## How to track it down 1. **Read the component name** in the message. It narrows the search to that component's template, hooks and watchers. 2. **Add `onRenderTriggered`** (development only) to log the `DebuggerEvent`: it names the target, the key and the operation that re-triggered the render, which usually points straight at the offending write. 3. **Use a watcher's `onTrigger` debug option** in the same way when a watcher is suspected. 4. **Search for writes in read paths**: methods called from the template, `onUpdated` bodies, watch callbacks that write any source they or another watcher observe. 5. **Bisect** by commenting out the suspected write; the error disappears when you remove the right one. ## How to fix it - **Derive instead of write.** A value computed from other state belongs in `computed`, which never writes and so cannot loop. - **Converge.** Guard the write so the second pass is a no-op — compare before assigning, clamp, or write a value that no longer changes. A ref write with the same value does not trigger. - **Move it out of the render path.** Templates and render functions should be pure reads; move side effects into event handlers or a watcher on the true source. - **Break mutual watchers** by making one value the single source of truth and deriving the other. ## What it is not - It is not a stack overflow: Vue counts job runs and throws its own error long before the call stack is the problem. - It is not caused by many writes in one handler — those are deduplicated into one job. - It is not fixed by wrapping the write in `nextTick()`: that only moves the write into a later flush, where it triggers the loop again, one flush at a time.
- Why does wrapping the offending write in nextTick() not fix a 'Maximum recursive updates exceeded' loop?It only moves the write into a later flush. That write still triggers the component or watcher, which writes again, so the loop continues one flush at a time — often without the error, because the count is per flush, and with the page doing endless work. The fix is a write that converges or a derived computed.
- Why can a plain watchEffect that increments a ref it reads not loop on its own?An effect that is currently running ignores triggers of itself unless it is explicitly allowed to recurse, and Vue grants that only to component render effects and watch callbacks. The watchEffect's own write therefore does not re-schedule it, although another effect reading that ref will still be triggered.
saying these in an interview costs you the question
- The error means too many writes happened in one click handler
- It is a JavaScript stack overflow from nested function calls
- Deferring the write with nextTick() fixes the loop
- Production builds throw the same error, so it is caught before release
- A computed that writes other state is the recommended fix