← blog
VueSeptember 14, 2026 · 12 min read

Vue nextTick Explained: How DOM Updates Are Batched

How Vue batches reactive writes into one DOM update, why the DOM looks stale right after a change, and how nextTick and watch's flush option fix it.

Parsa Jiravand · Frontend engineer · building bestpractic
Vue nextTick Explained: How DOM Updates Are Batched
  1. 1Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)14 min
  2. 2Vue nextTick Explained: How DOM Updates Are Batchedyou are here

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?

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

  • Explain why the DOM doesn't update the instant you write to a ref or a reactive object
  • 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 flush timing ('pre', 'post', 'sync') for a watcher that needs to see the DOM
  • Avoid the setTimeout workaround people reach for when they don't understand the batching

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).

Here's the naive version — the one that looks like it should obviously work:

Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<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:

JavaScript
1
2
3
4
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.

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 addAndScroll function keeps running — synchronously, on the current call stack. The document.querySelector line 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.

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:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
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() is how you wait for the flush explicitly instead of guessing with a timer:

JavaScript
1
2
3
4
5
6
7
8
9
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:

JavaScript
1
2
3
4
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>.

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.
JavaScript
1
2
3
4
5
6
7
8
9
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.

  • 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.
  • nextTick doesn'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's flushPromises() (or an await 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.

  • 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-if flips to true.
  • Prefer { flush: 'post' } on a watcher over a manual nextTick() 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 setTimeout as 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 ways nextTick isn'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 nextTick for normal template rendering. The template already handles the flush for you; nextTick is specifically for the moments where your own code, not the template, needs to touch the DOM.

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

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

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.

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.

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.

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."

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.

TaskCodeNotes
Wait for the DOM after a writeawait nextTick()Resolves after Vue's pending flush runs
Wait for the DOM, callback stylenextTick(() => { ... })Same guarantee, non-async context
Watcher that needs the updated DOMwatch(x, cb, { flush: 'post' })Declarative equivalent of nextTick inside a watcher
Watcher default timingwatch(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 yourself5 synchronous writes to one ref inside a watchEffectEffect runs once per flush, not once per write
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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' })

  • 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/watchEffect default to flush: 'pre'; reach for 'post' when the callback needs the DOM Vue just updated, and treat 'sync' as a rare escape hatch.
  • Never substitute setTimeout for nextTick. One is a documented contract; the other is a coincidence that happens to usually work.

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:

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.