[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-vue-weekly-nexttick-batching-dom-updates":44,"search-suggestions":60,"quiz-article-vue-weekly-nexttick-batching-dom-updates":105},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a04446-5aa3-7645-92d4-1f30c918311a","vue-weekly-nexttick-batching-dom-updates","PRACTICE_QUIZ","Vue's Update Scheduler: nextTick & Batching","Eight questions on why the DOM doesn't update the instant you mutate state, how Vue batches writes into one flush, and how nextTick and the watch flush option fit together.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,72,76,80,84,88,92,95,98,102],{"slug":62,"name":63,"articles":64},"webdev","Webdev",95,{"slug":66,"name":67,"articles":68},"javascript","Javascript",80,{"slug":70,"name":71,"articles":53},"frontend","Frontend",{"slug":73,"name":74,"articles":75},"css","Css",32,{"slug":77,"name":78,"articles":79},"tutorial","Tutorial",26,{"slug":81,"name":82,"articles":83},"typescript","Typescript",14,{"slug":85,"name":86,"articles":87},"performance","Performance",12,{"slug":89,"name":90,"articles":91},"react","React",11,{"slug":93,"name":94,"articles":51},"browser","Browser",{"slug":96,"name":97,"articles":51},"node","Node",{"slug":99,"name":100,"articles":101},"grammar","Grammar",6,{"slug":103,"name":104,"articles":101},"programming","Programming",{"id":106,"slug":46,"title":107,"subtitle":52,"excerpt":108,"coverUrl":109,"locale":13,"readingMinutes":87,"publishedAt":110,"viewCount":111,"likeCount":19,"commentCount":19,"author":112,"vertical":117,"topic":118,"tags":121,"_count":126,"playground":128,"body":130,"bodyMd":460,"seo":461,"translationGroupId":463,"series":464,"podcastUrl":52,"verticalId":5,"thread":472,"assessments":474,"translations":477,"quiz":479},"01a04446-5a00-7624-b775-c73c6c035e67","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.","\u002Fmedia\u002Fcovers\u002Fvue-weekly-nexttick-batching-dom-updates.png","2026-09-14T12:19:00.606Z",29,{"id":113,"name":114,"username":115,"avatarUrl":52,"headline":116},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":119,"name":120},"vue","Vue",[122,123,124,125],{"slug":119,"name":120,"color":52},{"slug":66,"name":67,"color":52},{"slug":77,"name":78,"color":52},{"slug":62,"name":63,"color":52},{"assessments":127},1,{"slug":46,"title":129},"Vue nextTick &amp; batching — interactive playground",{"blocks":131,"version":127},[132,136,139,142,147,150,159,162,165,168,171,183,186,189,194,197,200,205,208,212,215,218,225,228,231,234,237,241,244,247,250,254,257,261,264,267,271,274,278,281,287,291,294,297,305,309,317,320,323,326,329,332,335,338,341,344,347,350,353,356,360,363,366,369,373,376,380,383,386,418,422,425,433,436,439,442,445,448,451,454],{"id":133,"html":134,"type":135},"b1","\u003Cp>You click a button. It increments a \u003Ccode>ref\u003C\u002Fcode>. On the very next line, you read the element that&#39;s supposed to show that number — and it still says the old value. You didn&#39;t \u003Ccode>await\u003C\u002Fcode> anything wrong. You didn&#39;t forget a \u003Ccode>.value\u003C\u002Fcode>. Vue&#39;s reactivity did exactly what it was supposed to do, and the DOM is still lying to you for a few more microseconds.\u003C\u002Fp>","paragraph",{"id":137,"html":138,"type":135},"b2","\u003Cp>That gap between \u003Cem>the data changed\u003C\u002Fem> and \u003Cem>the DOM caught up\u003C\u002Fem> is not a bug to work around — it&#39;s a deliberate design decision, and understanding it explains a whole category of &quot;why isn&#39;t my DOM updated yet&quot; questions that \u003Ccode>nextTick()\u003C\u002Fcode> exists to answer.\u003C\u002Fp>",{"id":140,"html":141,"type":135},"b3","\u003Cp>This is the second episode of Vue Deep Dive. The \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij\">first one\u003C\u002Fa> 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?\u003C\u002Fp>",{"id":143,"html":144,"text":145,"type":146,"level":31},"b4","What you&#39;ll learn","What you'll learn","heading",{"id":148,"html":149,"type":135},"b5","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":151,"type":152,"items":153,"ordered":18},"b6","list",[154,155,156,157,158],"Explain why the DOM doesn&#39;t update the instant you write to a \u003Ccode>ref\u003C\u002Fcode> or a \u003Ccode>reactive\u003C\u002Fcode> object","Predict how many times a component re-renders when you change several pieces of state in a row","Use \u003Ccode>nextTick()\u003C\u002Fcode> correctly — and know what it&#39;s actually waiting for","Choose the right \u003Ccode>flush\u003C\u002Fcode> timing (\u003Ccode>&#39;pre&#39;\u003C\u002Fcode>, \u003Ccode>&#39;post&#39;\u003C\u002Fcode>, \u003Ccode>&#39;sync&#39;\u003C\u002Fcode>) for a watcher that needs to see the DOM","Avoid the \u003Ccode>setTimeout\u003C\u002Fcode> workaround people reach for when they don&#39;t understand the batching",{"id":160,"html":161,"text":161,"type":146,"level":31},"b7","Who this is for",{"id":163,"html":164,"type":135},"b8","\u003Cp>You&#39;ve built Vue components with \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode> and used \u003Ccode>ref()\u003C\u002Fcode>, \u003Ccode>watch()\u003C\u002Fcode>, or \u003Ccode>watchEffect()\u003C\u002Fcode>. You don&#39;t need to know how the scheduler is implemented — we&#39;ll build the mental model from the read-time subscription this series already covered.\u003C\u002Fp>",{"id":166,"html":167,"type":135},"b9","\u003Cp>This article is written against \u003Cstrong>Vue 3.5.x\u003C\u002Fstrong> (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).\u003C\u002Fp>",{"id":169,"html":170,"text":170,"type":146,"level":31},"b10","Table of contents",{"id":172,"type":152,"items":173,"ordered":18},"b11",[174,175,176,177,178,179,180,181,182],"\u003Ca href=\"#the-problem-the-dom-lies-to-you-for-a-moment\">The problem: the DOM lies to you for a moment\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-a-write-queues-a-job-it-doesnt-paint\">The mental model: a write queues a job, it doesn&#39;t paint\u003C\u002Fa>","\u003Ca href=\"#proof-five-writes-one-render\">Proof: five writes, one render\u003C\u002Fa>","\u003Ca href=\"#nexttick-the-checkpoint\">\u003Ccode>nextTick()\u003C\u002Fcode>: the checkpoint\u003C\u002Fa>","\u003Ca href=\"#watchers-and-the-flush-option\">Watchers and the \u003Ccode>flush\u003C\u002Fcode> option\u003C\u002Fa>","\u003Ca href=\"#edge-cases-and-gotchas\">Edge cases and gotchas\u003C\u002Fa>","\u003Ca href=\"#best-practices-when-you-actually-need-nexttick\">Best practices: when you actually need \u003Ccode>nextTick\u003C\u002Fcode>\u003C\u002Fa>","\u003Ca href=\"#faq\">FAQ\u003C\u002Fa>","\u003Ca href=\"#cheat-sheet\">Cheat sheet\u003C\u002Fa>",{"id":184,"html":185,"text":185,"type":146,"level":31},"b12","The problem: the DOM lies to you for a moment",{"id":187,"html":188,"type":135},"b13","\u003Cp>Here&#39;s the naive version — the one that looks like it should obviously work:\u003C\u002Fp>",{"id":190,"code":191,"type":192,"language":119,"highlight":193},"b14","\u003Cscript setup>\nimport { ref } from 'vue'\n\nconst items = ref(['a', 'b'])\n\nfunction addAndScroll() {\n  items.value.push('c')\n\n  \u002F\u002F \"It's in the array now, so the \u003Cli> must be in the DOM too.\"\n  const el = document.querySelector('li:last-child')\n  el.scrollIntoView({ behavior: 'smooth' }) \u002F\u002F scrolls to the OLD last item, or throws if the list was empty\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cul>\n    \u003Cli v-for=\"item in items\" :key=\"item\">{{ item }}\u003C\u002Fli>\n  \u003C\u002Ful>\n  \u003Cbutton @click=\"addAndScroll\">Add and scroll to it\u003C\u002Fbutton>\n\u003C\u002Ftemplate>","code",[],{"id":195,"html":196,"type":135},"b15","\u003Cp>The push happens. \u003Ccode>items.value\u003C\u002Fcode> really does have three elements the instant \u003Ccode>push\u003C\u002Fcode> returns — that part is ordinary, synchronous JavaScript. But \u003Ccode>document.querySelector(&#39;li:last-child&#39;)\u003C\u002Fcode> still finds the \u003Cem>old\u003C\u002Fem> last \u003Ccode>&lt;li&gt;\u003C\u002Fcode>, because the new one hasn&#39;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 \u003Ccode>null\u003C\u002Fcode> and throws.\u003C\u002Fp>",{"id":198,"html":199,"type":135},"b16","\u003Cp>The usual first fix is a \u003Ccode>setTimeout\u003C\u002Fcode>:\u003C\u002Fp>",{"id":201,"code":202,"type":192,"language":203,"highlight":204},"b17","items.value.push('c')\nsetTimeout(() => {\n  document.querySelector('li:last-child').scrollIntoView()\n}, 0)","js",[],{"id":206,"html":207,"type":135},"b18","\u003Cp>It &quot;works&quot; — most of the time, on most machines, which is exactly what makes it a bad fix. It&#39;s a delay chosen by superstition, not by a guarantee. Understanding \u003Cem>why\u003C\u002Fem> the DOM was stale tells you the actual guarantee to wait for.\u003C\u002Fp>",{"id":209,"html":210,"text":211,"type":146,"level":31},"b19","The mental model: a write queues a job, it doesn&#39;t paint","The mental model: a write queues a job, it doesn't paint",{"id":213,"html":214,"type":135},"b20","\u003Cp>\u003Cstrong>A write to reactive state doesn&#39;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.\u003C\u002Fstrong>\u003C\u002Fp>",{"id":216,"html":217,"type":135},"b21","\u003Cp>Walk through what actually happens when \u003Ccode>items.value.push(&#39;c&#39;)\u003C\u002Fcode> runs:\u003C\u002Fp>",{"id":219,"type":152,"items":220,"ordered":17},"b22",[221,222,223,224],"The array mutation triggers Vue&#39;s reactivity: every effect that read \u003Ccode>items.value\u003C\u002Fcode> (here, the component&#39;s render effect) is marked dirty.","That effect is \u003Cstrong>queued\u003C\u002Fstrong>, not run. Vue adds a job to an internal queue and, if a flush isn&#39;t already scheduled, schedules one via a microtask (\u003Ccode>Promise.resolve().then(...)\u003C\u002Fcode> under the hood).","Your \u003Ccode>addAndScroll\u003C\u002Fcode> function keeps running — synchronously, on the current call stack. The \u003Ccode>document.querySelector\u003C\u002Fcode> line executes \u003Cem>before\u003C\u002Fem> 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 \u003Ccode>&lt;li&gt;\u003C\u002Fcode>.",{"id":226,"html":227,"type":135},"b23","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> &quot;the effect ran&quot; and &quot;you read the result&quot; are two different events, separated by a microtask boundary you have to explicitly wait past.\u003C\u002Fp>",{"id":229,"html":230,"type":135},"b24","\u003Cp>This is not laziness on Vue&#39;s part — it&#39;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.\u003C\u002Fp>",{"id":232,"html":233,"text":233,"type":146,"level":31},"b25","Proof: five writes, one render",{"id":235,"html":236,"type":135},"b26","\u003Cp>Here&#39;s a \u003Ccode>watchEffect\u003C\u002Fcode> standing in for &quot;a render&quot; — it reads a ref, so it depends on it, and it prints every time it runs:\u003C\u002Fp>",{"id":238,"code":239,"type":192,"language":203,"highlight":240},"b27","import { ref, watchEffect } from 'vue'\n\nconst count = ref(0)\nlet runs = 0\n\nwatchEffect(() => {\n  void count.value \u002F\u002F reading it creates the dependency\n  runs++\n})\n\nfor (let i = 0; i \u003C 5; i++) count.value++\n\nconsole.log(runs) \u002F\u002F 1 — not 5",[],{"id":242,"html":243,"type":135},"b28","\u003Cp>Five writes to \u003Ccode>count\u003C\u002Fcode>, all synchronous, no \u003Ccode>await\u003C\u002Fcode> between them. If Vue reacted per write, \u003Ccode>runs\u003C\u002Fcode> would be \u003Ccode>5\u003C\u002Fcode> 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 \u003Cem>same already-queued job\u003C\u002Fem> dirty. There&#39;s one job per effect per flush cycle, not one job per write.\u003C\u002Fp>",{"id":245,"html":246,"type":135},"b29","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> batching isn&#39;t about which writes &quot;count&quot; — every one of them lands in the data. It&#39;s about deduping how many times the \u003Cem>effect\u003C\u002Fem> reruns per flush.\u003C\u002Fp>",{"id":248,"html":249,"type":135},"b30","\u003Cp>The \u003Ca href=\"#try-it-yourself\">interactive playground\u003C\u002Fa> 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.\u003C\u002Fp>",{"id":251,"html":252,"text":253,"type":146,"level":31},"b31","\u003Ccode>nextTick()\u003C\u002Fcode>: the checkpoint","nextTick(): the checkpoint",{"id":255,"html":256,"type":135},"b32","\u003Cp>\u003Ccode>nextTick()\u003C\u002Fcode> is how you wait for the flush explicitly instead of guessing with a timer:\u003C\u002Fp>",{"id":258,"code":259,"type":192,"language":203,"highlight":260},"b33","import { ref, nextTick } from 'vue'\n\nconst items = ref(['a', 'b'])\n\nasync function addAndScroll() {\n  items.value.push('c')\n  await nextTick() \u002F\u002F resolves after the queued render has flushed\n  document.querySelector('li:last-child').scrollIntoView({ behavior: 'smooth' })\n}",[],{"id":262,"html":263,"type":135},"b34","\u003Cp>\u003Ccode>nextTick()\u003C\u002Fcode> returns a promise. Awaiting it doesn&#39;t add an arbitrary delay — it resolves at the specific point where Vue&#39;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&#39;s a real contract, not a best guess about how long a render &quot;usually&quot; takes.\u003C\u002Fp>",{"id":265,"html":266,"type":135},"b35","\u003Cp>It also accepts an old-style callback, if you&#39;re not in an \u003Ccode>async\u003C\u002Fcode> context:\u003C\u002Fp>",{"id":268,"code":269,"type":192,"language":203,"highlight":270},"b36","items.value.push('c')\nnextTick(() => {\n  document.querySelector('li:last-child').scrollIntoView({ behavior: 'smooth' })\n})",[],{"id":272,"html":273,"type":135},"b37","\u003Cp>Both forms wait for the same thing. The promise form composes better with the rest of modern async code, so prefer it in \u003Ccode>&lt;script setup&gt;\u003C\u002Fcode>.\u003C\u002Fp>",{"id":275,"html":276,"text":277,"type":146,"level":31},"b38","Watchers and the \u003Ccode>flush\u003C\u002Fcode> option","Watchers and the flush option",{"id":279,"html":280,"type":135},"b39","\u003Cp>\u003Ccode>watch()\u003C\u002Fcode> and \u003Ccode>watchEffect()\u003C\u002Fcode> take a \u003Ccode>flush\u003C\u002Fcode> option that decides \u003Cem>when relative to the component&#39;s own DOM update\u003C\u002Fem> their callback runs. There are three values, and the middle one is the default:\u003C\u002Fp>",{"id":282,"type":152,"items":283,"ordered":18},"b40",[284,285,286],"\u003Cstrong>\u003Ccode>&#39;pre&#39;\u003C\u002Fcode>\u003C\u002Fstrong> (the default) — the callback runs before the owner component&#39;s DOM updates in that flush cycle. If the callback reads a template ref expecting the latest render, it&#39;ll see the \u003Cem>previous\u003C\u002Fem> one.","\u003Cstrong>\u003Ccode>&#39;post&#39;\u003C\u002Fcode>\u003C\u002Fstrong> — the callback runs after the owner component&#39;s DOM has updated. Use this when the watcher needs to read the DOM it&#39;s reacting to.","\u003Cstrong>\u003Ccode>&#39;sync&#39;\u003C\u002Fcode>\u003C\u002Fstrong> — 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.",{"id":288,"code":289,"type":192,"language":203,"highlight":290},"b41","import { ref, watch } from 'vue'\n\nconst query = ref('')\nconst resultsEl = ref(null) \u002F\u002F template ref\n\nwatch(query, () => {\n  \u002F\u002F 'post': resultsEl.value now points at the DOM Vue just rendered for the new query\n  resultsEl.value?.scrollTo({ top: 0 })\n}, { flush: 'post' })",[],{"id":292,"html":293,"type":135},"b42","\u003Cp>\u003Ccode>{ flush: &#39;post&#39; }\u003C\u002Fcode> here is doing the same job \u003Ccode>await nextTick()\u003C\u002Fcode> does inline — it&#39;s the declarative version, for when the &quot;wait for the DOM&quot; logic belongs inside a watcher rather than an event handler.\u003C\u002Fp>",{"id":295,"html":296,"text":296,"type":146,"level":31},"b43","Edge cases and gotchas",{"id":298,"type":152,"items":299,"ordered":18},"b44",[300,301,302,303,304],"\u003Cstrong>Calling \u003Ccode>nextTick()\u003C\u002Fcode> with nothing pending still resolves.\u003C\u002Fstrong> It&#39;s tied to a microtask checkpoint, not to &quot;an update happened.&quot; Calling it speculatively, even when you&#39;re not sure anything changed, is safe and won&#39;t hang.","\u003Cstrong>A \u003Ccode>&#39;sync&#39;\u003C\u002Fcode> watcher sees intermediate values a \u003Ccode>&#39;pre&#39;\u003C\u002Fcode>\u002F\u003Ccode>&#39;post&#39;\u003C\u002Fcode> watcher never will.\u003C\u002Fstrong> Because \u003Ccode>&#39;pre&#39;\u003C\u002Fcode> and \u003Ccode>&#39;post&#39;\u003C\u002Fcode> 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 \u003Ccode>&#39;sync&#39;\u003C\u002Fcode>, at the cost of losing all batching for that watcher.","\u003Cstrong>\u003Ccode>nextTick\u003C\u002Fcode> doesn&#39;t wait for CSS transitions or animations to finish.\u003C\u002Fstrong> It resolves once the DOM has the new content, not once the browser has finished painting it visually. If you need &quot;after the transition ends,&quot; listen for the transition&#39;s own end event instead.","\u003Cstrong>Testing utilities often need their own flush helper.\u003C\u002Fstrong> \u003Ccode>@vue\u002Ftest-utils\u003C\u002Fcode>&#39;s \u003Ccode>flushPromises()\u003C\u002Fcode> (or an \u003Ccode>await nextTick()\u003C\u002Fcode> 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.","\u003Cstrong>Multiple \u003Ccode>await nextTick()\u003C\u002Fcode> calls in a row don&#39;t each wait for a separate flush if nothing changed in between.\u003C\u002Fstrong> One flush covers every queued job; you don&#39;t need to await once per write.",{"id":306,"html":307,"text":308,"type":146,"level":31},"b45","Best practices: when you actually need \u003Ccode>nextTick\u003C\u002Fcode>","Best practices: when you actually need nextTick",{"id":310,"type":152,"items":311,"ordered":18},"b46",[312,313,314,315,316],"\u003Cstrong>Reach for it when you need to read or measure the real DOM right after a write\u003C\u002Fstrong> — scrolling a new element into view, measuring a just-rendered element&#39;s height, focusing an element that only exists after a \u003Ccode>v-if\u003C\u002Fcode> flips to \u003Ccode>true\u003C\u002Fcode>.","\u003Cstrong>Prefer \u003Ccode>{ flush: &#39;post&#39; }\u003C\u002Fcode> on a watcher over a manual \u003Ccode>nextTick()\u003C\u002Fcode> in a handler\u003C\u002Fstrong> when the &quot;wait for the DOM&quot; logic is really reacting to a piece of state changing, not to a specific user action. It keeps the dependency explicit.","\u003Cstrong>Never reach for \u003Ccode>setTimeout\u003C\u002Fcode> as a substitute.\u003C\u002Fstrong> It isn&#39;t a documented guarantee, it&#39;s slower than a microtask, and it can be delayed by a busy main thread or throttled in a background tab in ways \u003Ccode>nextTick\u003C\u002Fcode> isn&#39;t.","\u003Cstrong>Don&#39;t sprinkle \u003Ccode>flush: &#39;sync&#39;\u003C\u002Fcode> to &quot;fix&quot; a timing bug you don&#39;t understand yet.\u003C\u002Fstrong> It removes batching for that one watcher, which usually just trades a timing bug for a performance one. Reach for \u003Ccode>&#39;post&#39;\u003C\u002Fcode> first.","\u003Cstrong>You almost never need \u003Ccode>nextTick\u003C\u002Fcode> for normal template rendering.\u003C\u002Fstrong> The template already handles the flush for you; \u003Ccode>nextTick\u003C\u002Fcode> is specifically for the moments where \u003Cem>your own code\u003C\u002Fem>, not the template, needs to touch the DOM.",{"id":318,"html":319,"type":135},"b47","\u003C!-- playground:start -->",{"id":321,"html":322,"text":322,"type":146,"level":31},"b48","🎮 Try it yourself",{"id":324,"html":325,"type":135},"b49","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-nexttick-batching-dom-updates\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":327,"html":328,"type":135},"b50","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":330,"html":331,"type":135},"b51","\u003C!-- playground:end -->",{"id":333,"html":334,"type":135},"b52","\u003C!-- quiz:start -->",{"id":336,"html":337,"text":337,"type":146,"level":31},"b53","🧠 Test yourself",{"id":339,"html":340,"type":135},"b54","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-nexttick-batching-dom-updates\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":342,"html":343,"type":135},"b55","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":345,"html":346,"type":135},"b56","\u003C!-- quiz:end -->",{"id":348,"html":349,"text":349,"type":146,"level":31},"b57","FAQ",{"id":351,"html":352,"text":352,"type":146,"level":43},"b58","Why is the DOM not updated right after I change a ref?",{"id":354,"html":355,"type":135},"b59","\u003Cp>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.\u003C\u002Fp>",{"id":357,"html":358,"text":359,"type":146,"level":43},"b60","Is \u003Ccode>nextTick()\u003C\u002Fcode> the same as \u003Ccode>setTimeout(fn, 0)\u003C\u002Fcode>?","Is nextTick() the same as setTimeout(fn, 0)?",{"id":361,"html":362,"type":135},"b61","\u003Cp>No. \u003Ccode>nextTick\u003C\u002Fcode> is backed by the microtask queue, which the browser drains before it paints and before it processes timers. \u003Ccode>setTimeout(fn, 0)\u003C\u002Fcode> runs later, after at least one paint, and isn&#39;t a documented contract about when Vue&#39;s flush completes — it usually works by coincidence, not by guarantee.\u003C\u002Fp>",{"id":364,"html":365,"text":365,"type":146,"level":43},"b62","Does every reactive write trigger a separate render?",{"id":367,"html":368,"type":135},"b63","\u003Cp>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.\u003C\u002Fp>",{"id":370,"html":371,"text":372,"type":146,"level":43},"b64","Do I need \u003Ccode>nextTick\u003C\u002Fcode> if I&#39;m using a \u003Ccode>flush: &#39;post&#39;\u003C\u002Fcode> watcher instead?","Do I need nextTick if I'm using a flush: 'post' watcher instead?",{"id":374,"html":375,"type":135},"b65","\u003Cp>Not for that specific update — \u003Ccode>{ flush: &#39;post&#39; }\u003C\u002Fcode> gives the watcher the same &quot;DOM is current&quot; guarantee \u003Ccode>await nextTick()\u003C\u002Fcode> gives inline code. Use whichever fits the shape of the logic: a watcher for &quot;whenever this changes,&quot; \u003Ccode>nextTick\u003C\u002Fcode> for &quot;right after this one action.&quot;\u003C\u002Fp>",{"id":377,"html":378,"text":379,"type":146,"level":43},"b66","Does \u003Ccode>nextTick\u003C\u002Fcode> wait for CSS transitions to finish?","Does nextTick wait for CSS transitions to finish?",{"id":381,"html":382,"type":135},"b67","\u003Cp>No. It resolves once the DOM has the updated content, not once any transition or animation on it has visually completed. For &quot;after the transition,&quot; listen for the transition&#39;s own end event.\u003C\u002Fp>",{"id":384,"html":385,"text":385,"type":146,"level":31},"b68","Cheat sheet",{"id":387,"head":388,"rows":392,"type":417},"b69",[389,390,391],"Task","Code","Notes",[393,397,401,405,409,413],[394,395,396],"Wait for the DOM after a write","\u003Ccode>await nextTick()\u003C\u002Fcode>","Resolves after Vue&#39;s pending flush runs",[398,399,400],"Wait for the DOM, callback style","\u003Ccode>nextTick(() =&gt; { ... })\u003C\u002Fcode>","Same guarantee, non-async context",[402,403,404],"Watcher that needs the updated DOM","\u003Ccode>watch(x, cb, { flush: &#39;post&#39; })\u003C\u002Fcode>","Declarative equivalent of \u003Ccode>nextTick\u003C\u002Fcode> inside a watcher",[406,407,408],"Watcher default timing","\u003Ccode>watch(x, cb)\u003C\u002Fcode> → \u003Ccode>flush: &#39;pre&#39;\u003C\u002Fcode>","Runs before the component&#39;s own DOM update",[410,411,412],"Watcher with no batching (rare)","\u003Ccode>watch(x, cb, { flush: &#39;sync&#39; })\u003C\u002Fcode>","Runs on every write, immediately — usually the wrong tool",[414,415,416],"Prove batching yourself","5 synchronous writes to one ref inside a \u003Ccode>watchEffect\u003C\u002Fcode>","Effect runs once per flush, not once per write","table",{"id":419,"code":420,"type":192,"language":203,"highlight":421},"b70","\u002F\u002F The whole pattern in one place:\nimport { ref, nextTick, watch } from 'vue'\n\nconst items = ref([])\nconst listEl = ref(null) \u002F\u002F template ref on the \u003Cul>\n\nasync function addItem(value) {\n  items.value.push(value)       \u002F\u002F 1. write — queues a render, doesn't paint yet\n  await nextTick()              \u002F\u002F 2. wait for the flush — DOM now has the new \u003Cli>\n  listEl.value.lastElementChild?.scrollIntoView({ behavior: 'smooth' })\n}\n\n\u002F\u002F Declarative equivalent, reacting to the same kind of change:\nwatch(items, () => {\n  listEl.value?.scrollTo({ top: listEl.value.scrollHeight })\n}, { flush: 'post' })",[],{"id":423,"html":424,"text":424,"type":146,"level":31},"b71","Key takeaways",{"id":426,"type":152,"items":427,"ordered":18},"b72",[428,429,430,431,432],"\u003Cstrong>A write queues a job; it doesn&#39;t paint.\u003C\u002Fstrong> The DOM update happens on a microtask flush, not synchronously with the write that caused it.","\u003Cstrong>Vue batches.\u003C\u002Fstrong> Multiple synchronous writes to the same or different state before the next flush produce one render, not one per write — that&#39;s a feature, not a delay you&#39;re waiting out.","\u003Cstrong>\u003Ccode>nextTick()\u003C\u002Fcode> is a real checkpoint\u003C\u002Fstrong>, backed by the microtask queue, not a guess disguised as a timeout.","\u003Cstrong>\u003Ccode>watch\u003C\u002Fcode>\u002F\u003Ccode>watchEffect\u003C\u002Fcode> default to \u003Ccode>flush: &#39;pre&#39;\u003C\u002Fcode>\u003C\u002Fstrong>; reach for \u003Ccode>&#39;post&#39;\u003C\u002Fcode> when the callback needs the DOM Vue just updated, and treat \u003Ccode>&#39;sync&#39;\u003C\u002Fcode> as a rare escape hatch.","\u003Cstrong>Never substitute \u003Ccode>setTimeout\u003C\u002Fcode> for \u003Ccode>nextTick\u003C\u002Fcode>.\u003C\u002Fstrong> One is a documented contract; the other is a coincidence that happens to usually work.",{"id":434,"html":435,"text":435,"type":146,"level":31},"b73","Back to that stale read",{"id":437,"html":438,"type":135},"b74","\u003Cp>The \u003Ccode>&lt;li&gt;\u003C\u002Fcode> you couldn&#39;t find with \u003Ccode>document.querySelector\u003C\u002Fcode> right after \u003Ccode>push()\u003C\u002Fcode> wasn&#39;t missing — it just hadn&#39;t been built yet. The array had already changed; the render job for it was sitting in a queue, one microtask away from running. \u003Ccode>await nextTick()\u003C\u002Fcode> is the line that waits for that queue to empty before you touch the DOM again.\u003C\u002Fp>",{"id":440,"html":441,"type":135},"b75","\u003Cp>Once you see the write and the paint as two separate, queued events instead of one instantaneous one, &quot;why isn&#39;t the DOM updated yet&quot; stops being mysterious — it&#39;s just a question of which side of the flush your code is running on.\u003C\u002Fp>",{"id":443,"html":444,"type":135},"b76","\u003Cp>Have you shipped a \u003Ccode>setTimeout(fn, 0)\u003C\u002Fcode> to work around this before you knew what \u003Ccode>nextTick\u003C\u002Fcode> actually guaranteed? What finally made the batching click for you?\u003C\u002Fp>",{"id":446,"type":447},"b77","divider",{"id":449,"html":450,"type":135},"b78","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":452,"html":453,"type":135},"b79","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":455,"type":152,"items":456,"ordered":18},"b80",[457,458,459],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","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.\n\nThat 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.\n\nThis is the second episode of Vue Deep Dive. The [first one](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fvue-reactivity-explained-ref-vs-reactive-cheat-sheet-4nij) 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?\n\n## What you'll learn\n\nBy the end of this article you'll be able to:\n\n- Explain why the DOM doesn't update the instant you write to a `ref` or a `reactive` object\n- Predict how many times a component re-renders when you change several pieces of state in a row\n- Use `nextTick()` correctly — and know what it's actually waiting for\n- Choose the right `flush` timing (`'pre'`, `'post'`, `'sync'`) for a watcher that needs to see the DOM\n- Avoid the `setTimeout` workaround people reach for when they don't understand the batching\n\n## Who this is for\n\nYou've built Vue components with `\u003Cscript 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.\n\nThis 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).\n\n## Table of contents\n\n- [The problem: the DOM lies to you for a moment](#the-problem-the-dom-lies-to-you-for-a-moment)\n- [The mental model: a write queues a job, it doesn't paint](#the-mental-model-a-write-queues-a-job-it-doesnt-paint)\n- [Proof: five writes, one render](#proof-five-writes-one-render)\n- [`nextTick()`: the checkpoint](#nexttick-the-checkpoint)\n- [Watchers and the `flush` option](#watchers-and-the-flush-option)\n- [Edge cases and gotchas](#edge-cases-and-gotchas)\n- [Best practices: when you actually need `nextTick`](#best-practices-when-you-actually-need-nexttick)\n- [FAQ](#faq)\n- [Cheat sheet](#cheat-sheet)\n\n## The problem: the DOM lies to you for a moment\n\nHere's the naive version — the one that looks like it should obviously work:\n\n```vue\n\u003Cscript setup>\nimport { ref } from 'vue'\n\nconst items = ref(['a', 'b'])\n\nfunction addAndScroll() {\n  items.value.push('c')\n\n  \u002F\u002F \"It's in the array now, so the \u003Cli> must be in the DOM too.\"\n  const el = document.querySelector('li:last-child')\n  el.scrollIntoView({ behavior: 'smooth' }) \u002F\u002F scrolls to the OLD last item, or throws if the list was empty\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cul>\n    \u003Cli v-for=\"item in items\" :key=\"item\">{{ item }}\u003C\u002Fli>\n  \u003C\u002Ful>\n  \u003Cbutton @click=\"addAndScroll\">Add and scroll to it\u003C\u002Fbutton>\n\u003C\u002Ftemplate>\n```\n\nThe 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 `\u003Cli>`, 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.\n\nThe usual first fix is a `setTimeout`:\n\n```js\nitems.value.push('c')\nsetTimeout(() => {\n  document.querySelector('li:last-child').scrollIntoView()\n}, 0)\n```\n\nIt \"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.\n\n## The mental model: a write queues a job, it doesn't paint\n\n**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.**\n\nWalk through what actually happens when `items.value.push('c')` runs:\n\n1. The array mutation triggers Vue's reactivity: every effect that read `items.value` (here, the component's render effect) is marked dirty.\n2. 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).\n3. 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.\n4. 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 `\u003Cli>`.\n\n**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.\n\nThis 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.\n\n## Proof: five writes, one render\n\nHere's a `watchEffect` standing in for \"a render\" — it reads a ref, so it depends on it, and it prints every time it runs:\n\n```js\nimport { ref, watchEffect } from 'vue'\n\nconst count = ref(0)\nlet runs = 0\n\nwatchEffect(() => {\n  void count.value \u002F\u002F reading it creates the dependency\n  runs++\n})\n\nfor (let i = 0; i \u003C 5; i++) count.value++\n\nconsole.log(runs) \u002F\u002F 1 — not 5\n```\n\nFive 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.\n\n**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.\n\nThe [interactive playground](#try-it-yourself) 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.\n\n## `nextTick()`: the checkpoint\n\n`nextTick()` is how you wait for the flush explicitly instead of guessing with a timer:\n\n```js\nimport { ref, nextTick } from 'vue'\n\nconst items = ref(['a', 'b'])\n\nasync function addAndScroll() {\n  items.value.push('c')\n  await nextTick() \u002F\u002F resolves after the queued render has flushed\n  document.querySelector('li:last-child').scrollIntoView({ behavior: 'smooth' })\n}\n```\n\n`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.\n\nIt also accepts an old-style callback, if you're not in an `async` context:\n\n```js\nitems.value.push('c')\nnextTick(() => {\n  document.querySelector('li:last-child').scrollIntoView({ behavior: 'smooth' })\n})\n```\n\nBoth forms wait for the same thing. The promise form composes better with the rest of modern async code, so prefer it in `\u003Cscript setup>`.\n\n## Watchers and the `flush` option\n\n`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:\n\n- **`'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.\n- **`'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.\n- **`'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.\n\n```js\nimport { ref, watch } from 'vue'\n\nconst query = ref('')\nconst resultsEl = ref(null) \u002F\u002F template ref\n\nwatch(query, () => {\n  \u002F\u002F 'post': resultsEl.value now points at the DOM Vue just rendered for the new query\n  resultsEl.value?.scrollTo({ top: 0 })\n}, { flush: 'post' })\n```\n\n`{ 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.\n\n## Edge cases and gotchas\n\n- **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.\n- **A `'sync'` watcher sees intermediate values a `'pre'`\u002F`'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.\n- **`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.\n- **Testing utilities often need their own flush helper.** `@vue\u002Ftest-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.\n- **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.\n\n## Best practices: when you actually need `nextTick`\n\n- **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`.\n- **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.\n- **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.\n- **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.\n- **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.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-nexttick-batching-dom-updates\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-nexttick-batching-dom-updates\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n## FAQ\n\n### Why is the DOM not updated right after I change a ref?\n\nBecause 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.\n\n### Is `nextTick()` the same as `setTimeout(fn, 0)`?\n\nNo. `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.\n\n### Does every reactive write trigger a separate render?\n\nNo. 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.\n\n### Do I need `nextTick` if I'm using a `flush: 'post'` watcher instead?\n\nNot 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.\"\n\n### Does `nextTick` wait for CSS transitions to finish?\n\nNo. 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.\n\n## Cheat sheet\n\n| Task | Code | Notes |\n| --- | --- | --- |\n| Wait for the DOM after a write | `await nextTick()` | Resolves after Vue's pending flush runs |\n| Wait for the DOM, callback style | `nextTick(() => { ... })` | Same guarantee, non-async context |\n| Watcher that needs the updated DOM | `watch(x, cb, { flush: 'post' })` | Declarative equivalent of `nextTick` inside a watcher |\n| Watcher default timing | `watch(x, cb)` → `flush: 'pre'` | Runs before the component's own DOM update |\n| Watcher with no batching (rare) | `watch(x, cb, { flush: 'sync' })` | Runs on every write, immediately — usually the wrong tool |\n| Prove batching yourself | 5 synchronous writes to one ref inside a `watchEffect` | Effect runs once per flush, not once per write |\n\n```js\n\u002F\u002F The whole pattern in one place:\nimport { ref, nextTick, watch } from 'vue'\n\nconst items = ref([])\nconst listEl = ref(null) \u002F\u002F template ref on the \u003Cul>\n\nasync function addItem(value) {\n  items.value.push(value)       \u002F\u002F 1. write — queues a render, doesn't paint yet\n  await nextTick()              \u002F\u002F 2. wait for the flush — DOM now has the new \u003Cli>\n  listEl.value.lastElementChild?.scrollIntoView({ behavior: 'smooth' })\n}\n\n\u002F\u002F Declarative equivalent, reacting to the same kind of change:\nwatch(items, () => {\n  listEl.value?.scrollTo({ top: listEl.value.scrollHeight })\n}, { flush: 'post' })\n```\n\n## Key takeaways\n\n- **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.\n- **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.\n- **`nextTick()` is a real checkpoint**, backed by the microtask queue, not a guess disguised as a timeout.\n- **`watch`\u002F`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.\n- **Never substitute `setTimeout` for `nextTick`.** One is a documented contract; the other is a coincidence that happens to usually work.\n\n## Back to that stale read\n\nThe `\u003Cli>` 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.\n\nOnce 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.\n\nHave 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?\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":107,"canonical":462,"description":108},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fvue-weekly-nexttick-batching-dom-updates","01a04446-5a02-762e-8ea8-a85edd9da0c6",{"name":465,"part":31,"total":31,"items":466},"Vue Deep Dive",[467,471],{"slug":468,"title":469,"publishedAt":470,"readingMinutes":83},"vue-weekly-reactivity-ref-vs-reactive","Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)","2026-08-24T13:57:38.080Z",{"slug":46,"title":107,"publishedAt":110,"readingMinutes":87},{"id":473,"locked":18},"01a04446-5a57-751f-bb9b-0a4aed00d889",[475],{"id":45,"slug":46,"title":48,"_count":476},{"questions":51},[478],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":480,"questionCount":51},{"questions":51}]