in Vue 3, truly understood
If you change ten refs one after another, Vue does not render ten times, it renders once. The reactivity scheduler batches DOM updates through the microtask queue, and nextTick() is the tool for reliably waiting for exactly that moment instead of relying on accidental timing.
Table of Contents
- 1. Why synchronous rendering on every ref change would be a problem
- 2. The scheduler in detail: microtask queue and job dedupe
- 3. Using nextTick() correctly
- 4. Why multiple mutations trigger only one update
- 5. Understanding flush timing: pre, post, and sync
- 6. Debugging watch callback and DOM update order
- 7. nextTick() in tests with Vitest and Vue Test Utils
- 8. Common mistakes with the scheduler and nextTick
- 9. Synchronous updates compared to scheduler batching
- 10. Summary
- 11. FAQ
1. Why synchronous rendering on every ref change would be a problem
If Vue triggered a complete re-render and DOM update synchronously on every single ref assignment, that would have serious performance consequences. A function that changes ten reactive values one after another, for example when resetting a form, would trigger ten separate DOM updates, even though only the final result needs to be visible to the user. The reactivity scheduler prevents exactly that.
Instead of rendering synchronously, the reactivity scheduler collects all triggered update jobs in a queue and runs them together in the next microtask, after all the synchronous JavaScript code that caused the changes has fully finished running. For ten ref changes in the same function, that means a single DOM update instead of ten.
This batching is not an optional optimization you have to turn on, it is baked firmly into the core of Vue's reactivity system. If you want to understand why a component behaves differently than expected, for example when accessing a DOM element right after an assignment, you need to understand the reactivity scheduler and its timing, because that exact timing is the cause of most surprises.
2. The scheduler in detail: microtask queue and job dedupe
Internally, the reactivity scheduler maintains a job queue into which every component whose reactive dependencies have changed enqueues a render job. If another dependency of the same component changes within the same synchronous execution section, no second job gets added, the existing job is recognized as already scheduled and deduplicated. This exact job dedupe mechanism ensures that a component renders at most once per flush cycle, regardless of how many of its reactive dependencies changed.
The queue is scheduled as a microtask via Promise.resolve().then(), not via setTimeout(). Microtasks are processed by the JavaScript event loop before the next rendering frame and before any macrotasks like setTimeout, which means DOM updates happen as early as possible after the current synchronous code, without waiting for the next browser frame.
import { ref, watchEffect } from 'vue'
const a = ref(0)
const b = ref(0)
watchEffect(() => {
console.log('effect ran, a + b =', a.value + b.value)
})
function updateBoth() {
a.value = 1 // schedules a job for this effect
b.value = 2 // same effect, job already queued — deduplicated
console.log('synchronous code finished')
}
updateBoth()
// Console output order:
// "synchronous code finished"
// "effect ran, a + b = 3" <- runs once, in the microtask queue, not twice
3. Using nextTick() correctly
nextTick() returns a promise that resolves exactly when the current scheduler flush has completed, meaning after all pending DOM updates have been applied. That makes nextTick() the right tool whenever you need to access the actually updated DOM after a reactive state change, for example to measure the height of a newly inserted element or to focus a newly rendered input field.
Without nextTick(), a direct access to a template ref's .value right after a state change would still return the old DOM state, because the scheduler has not yet run the update. The solution is either to use await nextTick() inside an async function, or to call nextTick(callback) with a callback function that only runs after the DOM update.
import { ref, nextTick } from 'vue'
const showInput = ref(false)
const inputRef = ref(null)
async function revealAndFocus() {
showInput.value = true // triggers a scheduler job, DOM not updated yet here
// WITHOUT nextTick, inputRef.value would still be null at this point
await nextTick() // waits until the DOM update has actually been applied
inputRef.value?.focus() // now the element exists and can be focused
}
4. Why multiple mutations trigger only one update
The practical effect of batching shows most clearly in a function that sets several fields of a reactive object in sequence, for example when resetting a form back to default values. Without scheduler batching, every single field change would trigger its own re-render of the affected component, with noticeable performance costs in larger component trees and potentially visible flicker between intermediate states.
With the reactivity scheduler, the user only ever sees the final state after all synchronous assignments have completed, never any of the intermediate states. That is an important mental model: inside a synchronous function, you can change reactive values as many times as you want without worrying about intermediate render states, because the scheduler guarantees it only renders after the synchronous code has finished.
import { reactive } from 'vue'
const form = reactive({
name: '',
email: '',
agreedToTerms: false
})
function resetForm() {
// All three mutations happen synchronously —
// the component re-renders only ONCE after this function returns,
// never showing an intermediate state to the user
form.name = ''
form.email = ''
form.agreedToTerms = false
}
5. Understanding flush timing: pre, post, and sync
watch() and watchEffect() accept a flush option that determines when the callback runs relative to the component update cycle. flush: 'pre', the default, runs the callback before the component update. flush: 'post' delays execution to after the DOM update, so the updated DOM can be safely accessed inside the callback without additionally calling nextTick(). flush: 'sync' runs the callback fully synchronously, immediately on every change, without any batching by the scheduler.
flush: 'sync' should be used with care, since it completely bypasses batching behavior and can cause noticeable performance losses with frequent changes. The main use case for flush: 'post' is exactly the scenario from section 3: accessing the DOM after a state change, but without the extra detour through nextTick() inside the watch callback function itself.
import { ref, watch } from 'vue'
const items = ref([])
const listRef = ref(null)
// flush: 'post' guarantees the DOM is already updated when this runs
watch(items, () => {
// Safe to read listRef's updated scrollHeight here, no nextTick() needed
if (listRef.value) {
listRef.value.scrollTop = listRef.value.scrollHeight
}
}, { flush: 'post' })
// flush: 'sync' — runs immediately, bypasses the scheduler entirely
watch(items, () => {
console.log('runs synchronously, before the scheduler batches anything')
}, { flush: 'sync' })
6. Debugging watch callback and DOM update order
A common debugging scenario: a watch() callback accesses a template ref and seems to get stale values, even though the underlying ref change happened long ago. The cause is almost always incorrect flush timing. With the default flush: 'pre', the callback runs before the DOM update, so accessing updated layout values like offsetHeight still returns the old values.
The systematic debugging strategy: first check whether the callback is actually supposed to run before or after the DOM update, and set the flush timing explicitly accordingly, instead of relying on the default. With more complex chains of multiple watch() calls using different flush timings, the order can get confusing on top of that. Here it helps to give each callback a distinct console.log() prefix and observe the actual execution order in the browser, rather than assuming it.
7. nextTick() in tests with Vitest and Vue Test Utils
In component tests with Vue Test Utils, await nextTick() after every simulated user interaction that triggers a reactive state change is practically mandatory. Without that await, a subsequent assertion checks the DOM state before the scheduler has actually updated it, resulting in a test that appears to fail randomly, depending on how quickly the test environment processes the microtask queue.
wrapper.trigger('click') in Vue Test Utils already returns a promise that internally awaits nextTick(), which already covers this particular case. For manual state changes made directly through the component instance or an exposed composable, however, an explicit await nextTick() is still necessary before checking assertions against the DOM state.
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import Counter from './Counter.vue'
describe('Counter', () => {
it('updates the DOM after state change', async () => {
const wrapper = mount(Counter)
// Directly mutating exposed reactive state, not via a user event
wrapper.vm.count = 5
// WITHOUT this, the assertion below could read stale DOM content
await nextTick()
expect(wrapper.find('[data-testid="count"]').text()).toBe('5')
})
})
8. Common mistakes with the scheduler and nextTick
The most common mistake is accessing DOM properties synchronously right after a reactive state change, without waiting for nextTick(), assuming Vue would update the DOM immediately. The result is a subtle bug that shows up differently in development environments with slower devices, or in CI pipelines with different timing characteristics than locally, which makes it harder to track down.
A second mistake is overusing flush: 'sync' as a seemingly easy fix for timing problems. That superficially works around the issue, but with frequent changes it causes exactly the performance costs the reactivity scheduler is meant to avoid. In the vast majority of cases, flush: 'post' or a clean await nextTick() is the correct, more performant solution.
9. Synchronous updates compared to scheduler batching
The overview below shows the practical difference between accidentally synchronous behavior and correctly working with the scheduler.
| Scenario | Wrong approach | Right approach | Result |
|---|---|---|---|
| DOM access after a state change | Immediate access without nextTick() | Await nextTick() first | Always up to date DOM state |
| watch() with DOM access | Default flush: 'pre' plus manual nextTick() | Set flush: 'post' directly | Less code, clearer intent |
| Multiple field changes | Individual setTimeout(0) per field | All changes synchronously in the same tick | Scheduler batches automatically |
| Test after simulated mutation | Assertion right after assignment | await nextTick() before assertion | Stable, deterministic tests |
| Quickly fixing a timing issue | Using flush: 'sync' everywhere | Targeted flush: 'post' or nextTick() | Batching benefits are preserved |
The basic rule: the reactivity scheduler batches updates automatically, and nextTick() or flush: 'post' are the intended tools to reliably access the DOM after that batching point. flush: 'sync' stays reserved for real edge cases where synchronous behavior is explicitly needed, for example very fine grained interactions with external, non reactive libraries.
Mironsoft
Vue 3 debugging for timing and reactivity issues
Fighting strange render ordering in Vue 3?
We analyze scheduler timing issues in your components, put flush options and nextTick() usage on a clean foundation, and stabilize your tests.
Timing audit
Identifying faulty DOM access and race conditions
Flush timing refactoring
watch() calls with the right flush option instead of workarounds
Test stabilization
Fixing flaky tests through correct nextTick() usage
10. Summary
The reactivity scheduler batches multiple synchronous ref changes through the microtask queue into a single DOM update per component, instead of rendering immediately on every single change. Job dedupe ensures a component updates at most once per flush cycle, no matter how many of its reactive dependencies changed. nextTick() is the tool for reliably accessing the actually updated DOM after that batching point.
Flush timing with pre, post, and sync controls when a watch() callback runs relative to the DOM update, with post being the cleaner alternative to a manual nextTick() inside the callback for DOM access. In tests, await nextTick() after every direct state mutation is essential to avoid race conditions between the assertion and the scheduler flush. flush: 'sync' stays deliberately reserved for edge cases, since it bypasses batching entirely.
Reactivity Scheduler and nextTick in Vue 3 — The Essentials at a Glance
Core principle
Updates are batched through the microtask queue, at most one render per component per tick.
nextTick()
A promise resolved once the current scheduler flush has completed.
Flush options
pre (default), post (after DOM update), sync (immediate, no batching).
Most common mistake
DOM access right after a state change without nextTick(), or wrong flush timing.