You click a button. It increments a ref. On the very next line, you read the element that's supposed to show that number — and it still says the old value. You didn't await anything wrong. You didn't forget a .value. Vue's reactivity did exactly what it was supposed to do, and the DOM is still lying to you for a few more microseconds.
That gap between the data changed and the DOM caught up is not a bug to work around — it's a deliberate design decision, and understanding it explains a whole category of "why isn't my DOM updated yet" questions that nextTick() exists to answer.
This is the second episode of Vue Deep Dive. The first one covered how Vue tracks a dependency when an effect reads a reactive property. This one picks up exactly where that left off: once a dependency changes, what does Vue actually do with it, and when?
What you'll learn
By the end of this article you'll be able to:
- Explain why the DOM doesn't update the instant you write to a
refor areactiveobject - Predict how many times a component re-renders when you change several pieces of state in a row
- Use
nextTick()correctly — and know what it's actually waiting for - Choose the right
flushtiming ('pre','post','sync') for a watcher that needs to see the DOM - Avoid the
setTimeoutworkaround people reach for when they don't understand the batching
Who this is for
You've built Vue components with <script setup> and used ref(), watch(), or watchEffect(). You don't need to know how the scheduler is implemented — we'll build the mental model from the read-time subscription this series already covered.
This article is written against Vue 3.5.x (verified August 2026; Vue 3.6 is in release candidate as this goes out, and the scheduling behavior described here is the stable, documented one).
Table of contents
- The problem: the DOM lies to you for a moment
- The mental model: a write queues a job, it doesn't paint
- Proof: five writes, one render
nextTick(): the checkpoint- Watchers and the
flushoption - Edge cases and gotchas
- Best practices: when you actually need
nextTick - FAQ
- Cheat sheet
The problem: the DOM lies to you for a moment
Here's the naive version — the one that looks like it should obviously work:
<script setup>
import { ref } from 'vue'
const items = ref(['a', 'b'])
function addAndScroll() {
items.value.push('c')
// "It's in the array now, so the <li> must be in the DOM too."
const el = document.querySelector('li:last-child')
el.scrollIntoView({ behavior: 'smooth' }) // scrolls to the OLD last item, or throws if the list was empty
}
</script>
<template>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
<button @click="addAndScroll">Add and scroll to it</button>
</template>
The push happens. items.value really does have three elements the instant push returns — that part is ordinary, synchronous JavaScript. But document.querySelector('li:last-child') still finds the old last <li>, because the new one hasn't been rendered yet. Scroll to a list of two items and this either scrolls to the wrong element or, on an empty list, returns null and throws.
The usual first fix is a setTimeout:
items.value.push('c')
setTimeout(() => {
document.querySelector('li:last-child').scrollIntoView()
}, 0)
It "works" — most of the time, on most machines, which is exactly what makes it a bad fix. It's a delay chosen by superstition, not by a guarantee. Understanding why the DOM was stale tells you the actual guarantee to wait for.
The mental model: a write queues a job, it doesn't paint
A write to reactive state doesn't update the DOM. It marks a render job dirty and adds it to a queue. Vue flushes that queue once, on a microtask — after your current synchronous code finishes, and before the browser paints.
Walk through what actually happens when items.value.push('c') runs:
- The array mutation triggers Vue's reactivity: every effect that read
items.value(here, the component's render effect) is marked dirty. - That effect is queued, not run. Vue adds a job to an internal queue and, if a flush isn't already scheduled, schedules one via a microtask (
Promise.resolve().then(...)under the hood). - Your
addAndScrollfunction keeps running — synchronously, on the current call stack. Thedocument.querySelectorline executes before that microtask has a chance to run, because microtasks only run once the current synchronous code finishes. - Only after your function returns does the microtask queue get a turn: the flush runs, the render effect re-executes, and the real DOM gets the new
<li>.
Key concept: "the effect ran" and "you read the result" are two different events, separated by a microtask boundary you have to explicitly wait past.
This is not laziness on Vue's part — it's the entire point. If Vue re-rendered synchronously on every single reactive write, changing ten properties in one function would trigger ten renders. Batching collapses that into one.
Proof: five writes, one render
Here's a watchEffect standing in for "a render" — it reads a ref, so it depends on it, and it prints every time it runs:
import { ref, watchEffect } from 'vue'
const count = ref(0)
let runs = 0
watchEffect(() => {
void count.value // reading it creates the dependency
runs++
})
for (let i = 0; i < 5; i++) count.value++
console.log(runs) // 1 — not 5
Five writes to count, all synchronous, no await between them. If Vue reacted per write, runs would be 5 by the time the loop finished — but the loop finishes before the microtask queue gets a turn, so none of the five writes have caused a re-run yet. Once the flush does happen, it happens exactly once, because the second, third, fourth, and fifth writes just re-mark the same already-queued job dirty. There's one job per effect per flush cycle, not one job per write.
Key concept: batching isn't about which writes "count" — every one of them lands in the data. It's about deduping how many times the effect reruns per flush.
The interactive playground below runs this exact experiment against the real Vue runtime, plus the DOM-read-timing experiment from the previous section — you can watch the effect count stay at 1 no matter how many synchronous writes you make.
nextTick(): the checkpoint
nextTick() is how you wait for the flush explicitly instead of guessing with a timer:
import { ref, nextTick } from 'vue'
const items = ref(['a', 'b'])
async function addAndScroll() {
items.value.push('c')
await nextTick() // resolves after the queued render has flushed
document.querySelector('li:last-child').scrollIntoView({ behavior: 'smooth' })
}
nextTick() returns a promise. Awaiting it doesn't add an arbitrary delay — it resolves at the specific point where Vue's own pending flush has run, so every write made before the call is guaranteed to be reflected in the DOM by the time execution continues. That's a real contract, not a best guess about how long a render "usually" takes.
It also accepts an old-style callback, if you're not in an async context:
items.value.push('c')
nextTick(() => {
document.querySelector('li:last-child').scrollIntoView({ behavior: 'smooth' })
})
Both forms wait for the same thing. The promise form composes better with the rest of modern async code, so prefer it in <script setup>.
Watchers and the flush option
watch() and watchEffect() take a flush option that decides when relative to the component's own DOM update their callback runs. There are three values, and the middle one is the default:
-
'pre'(the default) — the callback runs before the owner component's DOM updates in that flush cycle. If the callback reads a template ref expecting the latest render, it'll see the previous one. -
'post'— the callback runs after the owner component's DOM has updated. Use this when the watcher needs to read the DOM it's reacting to. -
'sync'— the callback runs synchronously, immediately, with no batching at all. The docs call this explicitly inefficient and say it should rarely be needed — every write triggers a full callback run, defeating the entire point of the queue.
import { ref, watch } from 'vue'
const query = ref('')
const resultsEl = ref(null) // template ref
watch(query, () => {
// 'post': resultsEl.value now points at the DOM Vue just rendered for the new query
resultsEl.value?.scrollTo({ top: 0 })
}, { flush: 'post' })
{ flush: 'post' } here is doing the same job await nextTick() does inline — it's the declarative version, for when the "wait for the DOM" logic belongs inside a watcher rather than an event handler.
Edge cases and gotchas
-
Calling
nextTick()with nothing pending still resolves. It's tied to a microtask checkpoint, not to "an update happened." Calling it speculatively, even when you're not sure anything changed, is safe and won't hang. -
A
'sync'watcher sees intermediate values a'pre'/'post'watcher never will. Because'pre'and'post'callbacks are buffered — Vue skips interim values and calls the watcher once with the latest state — code relying on catching every single intermediate write needs'sync', at the cost of losing all batching for that watcher. -
nextTickdoesn't wait for CSS transitions or animations to finish. It resolves once the DOM has the new content, not once the browser has finished painting it visually. If you need "after the transition ends," listen for the transition's own end event instead. -
Testing utilities often need their own flush helper.
@vue/test-utils'sflushPromises()(or anawait nextTick()in the test itself) exists specifically because assertions written right after a state change in a test hit the same staleness this article describes — tests are just another piece of synchronous code racing the microtask queue. -
Multiple
await nextTick()calls in a row don't each wait for a separate flush if nothing changed in between. One flush covers every queued job; you don't need to await once per write.
Best practices: when you actually need nextTick
-
Reach for it when you need to read or measure the real DOM right after a write — scrolling a new element into view, measuring a just-rendered element's height, focusing an element that only exists after a
v-ifflips totrue. -
Prefer
{ flush: 'post' }on a watcher over a manualnextTick()in a handler when the "wait for the DOM" logic is really reacting to a piece of state changing, not to a specific user action. It keeps the dependency explicit. -
Never reach for
setTimeoutas a substitute. It isn't a documented guarantee, it's slower than a microtask, and it can be delayed by a busy main thread or throttled in a background tab in waysnextTickisn't. -
Don't sprinkle
flush: 'sync'to "fix" a timing bug you don't understand yet. It removes batching for that one watcher, which usually just trades a timing bug for a performance one. Reach for'post'first. -
You almost never need
nextTickfor normal template rendering. The template already handles the flush for you;nextTickis specifically for the moments where your own code, not the template, needs to touch the DOM.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
FAQ
Why is the DOM not updated right after I change a ref?
Because the write queues a render job instead of running it synchronously. Vue flushes the queue on a microtask, which runs only after your current synchronous code finishes — so code on the next line still sees the pre-update DOM.
Is nextTick() the same as setTimeout(fn, 0)?
No. nextTick is backed by the microtask queue, which the browser drains before it paints and before it processes timers. setTimeout(fn, 0) runs later, after at least one paint, and isn't a documented contract about when Vue's flush completes — it usually works by coincidence, not by guarantee.
Does every reactive write trigger a separate render?
No. Vue batches: multiple synchronous writes to the same or different pieces of state before the next flush collapse into one render pass, not one per write.
Do I need nextTick if I'm using a flush: 'post' watcher instead?
Not for that specific update — { flush: 'post' } gives the watcher the same "DOM is current" guarantee await nextTick() gives inline code. Use whichever fits the shape of the logic: a watcher for "whenever this changes," nextTick for "right after this one action."
Does nextTick wait for CSS transitions to finish?
No. It resolves once the DOM has the updated content, not once any transition or animation on it has visually completed. For "after the transition," listen for the transition's own end event.
Cheat sheet
| Task | Code | Notes |
|---|---|---|
| Wait for the DOM after a write | await nextTick() |
Resolves after Vue's pending flush runs |
| Wait for the DOM, callback style | nextTick(() => { ... }) |
Same guarantee, non-async context |
| Watcher that needs the updated DOM | watch(x, cb, { flush: 'post' }) |
Declarative equivalent of nextTick inside a watcher |
| Watcher default timing |
watch(x, cb) → flush: 'pre'
|
Runs before the component's own DOM update |
| Watcher with no batching (rare) | watch(x, cb, { flush: 'sync' }) |
Runs on every write, immediately — usually the wrong tool |
| Prove batching yourself | 5 synchronous writes to one ref inside a watchEffect
|
Effect runs once per flush, not once per write |
// The whole pattern in one place:
import { ref, nextTick, watch } from 'vue'
const items = ref([])
const listEl = ref(null) // template ref on the <ul>
async function addItem(value) {
items.value.push(value) // 1. write — queues a render, doesn't paint yet
await nextTick() // 2. wait for the flush — DOM now has the new <li>
listEl.value.lastElementChild?.scrollIntoView({ behavior: 'smooth' })
}
// Declarative equivalent, reacting to the same kind of change:
watch(items, () => {
listEl.value?.scrollTo({ top: listEl.value.scrollHeight })
}, { flush: 'post' })
Key takeaways
- A write queues a job; it doesn't paint. The DOM update happens on a microtask flush, not synchronously with the write that caused it.
- Vue batches. Multiple synchronous writes to the same or different state before the next flush produce one render, not one per write — that's a feature, not a delay you're waiting out.
-
nextTick()is a real checkpoint, backed by the microtask queue, not a guess disguised as a timeout. -
watch/watchEffectdefault toflush: 'pre'; reach for'post'when the callback needs the DOM Vue just updated, and treat'sync'as a rare escape hatch. -
Never substitute
setTimeoutfornextTick. One is a documented contract; the other is a coincidence that happens to usually work.
Back to that stale read
The <li> you couldn't find with document.querySelector right after push() wasn't missing — it just hadn't been built yet. The array had already changed; the render job for it was sitting in a queue, one microtask away from running. await nextTick() is the line that waits for that queue to empty before you touch the DOM again.
Once you see the write and the paint as two separate, queued events instead of one instantaneous one, "why isn't the DOM updated yet" stops being mysterious — it's just a question of which side of the flush your code is running on.
Have you shipped a setTimeout(fn, 0) to work around this before you knew what nextTick actually guaranteed? What finally made the batching click for you?
🚀 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:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___