Vue Reactivity and Layout Thrashing

Vue’s asynchronous reactivity system batches DOM writes through its watcher flush queue and exposes nextTick to read updated geometry safely β€” yet manual layout reads inside watchers, computed getters, or before nextTick resolves still force synchronous reflow. This guide compares the safe and unsafe patterns. It builds on Forced Synchronous Layouts, part of Layout and Paint Optimization.

How Vue Batches Writes

When reactive state changes, Vue does not patch the DOM immediately. It queues the affected component’s render effect in a flush queue and drains it on a microtask, deduplicating so each component re-renders at most once per tick. This means a burst of state mutations collapses into a single DOM update and therefore a single layout invalidation β€” the framework is doing your write-batching for you.

nextTick() returns a promise that resolves after that flush queue has drained and the DOM reflects the latest state. Reading geometry inside a nextTick callback is safe and cheap, because the write has already happened and you are reading once, post-update.

// βœ… Safe: read geometry after Vue has flushed its DOM writes
import { nextTick, ref } from 'vue'

const expanded = ref(false)
async function toggle() {
  expanded.value = true        // queued write β€” no DOM touch yet
  await nextTick()             // flush queue drains, DOM updated once
  const h = panel.value.offsetHeight // single read, layout tree already clean
  panel.value.style.setProperty('--measured', `${h}px`)
}

Where Reflow Sneaks Back In

The batching guarantee only covers writes Vue itself performs. The moment your code reads layout geometry while a reactive write is still pending, you reintroduce the read-after-write pattern and force a synchronous flush.

Reads inside a watcher, before nextTick

A watcher fires as part of the flush, but if it reads geometry and the same tick has further pending render effects, the read forces layout early:

// ❌ Forces synchronous layout: reading geometry inside the watcher body
watch(items, () => {
  // The list re-render for `items` may not be applied yet this tick.
  const top = list.value.scrollHeight  // read: forces layout flush now
  list.value.scrollTop = top           // write: dirties layout again
})

The cure is to defer the measurement to nextTick, so the list DOM is fully patched and you read once:

// βœ… Defer the read until the DOM reflects the new items
watch(items, async () => {
  await nextTick()
  list.value.scrollTop = list.value.scrollHeight // one flush, one write
})

Geometry inside a computed property

Computed getters are meant to be pure, cached derivations of reactive state. Reading getBoundingClientRect() inside one is doubly wrong: it forces layout and the result is not reactive, so the cache goes stale. Move geometry out to a watcher or a ResizeObserver.

// ❌ Anti-pattern: layout read in a computed getter
const width = computed(() => el.value.getBoundingClientRect().width) // forces reflow, non-reactive
// βœ… ResizeObserver feeds a ref; computed stays pure
const width = ref(0)
onMounted(() => {
  const ro = new ResizeObserver(([e]) => { width.value = e.contentRect.width })
  ro.observe(el.value) // reports geometry post-layout β€” no forced flush
})

Comparing the Patterns

Pattern When read runs Forces reflow?
Read inside nextTick callback After flush queue drains No
ResizeObserver / IntersectionObserver After layout settles No
Read inside watcher body, pre-nextTick Mid-flush, write pending Yes
Geometry read inside computed getter On dependency access Yes
Loop reading offsetHeight then writing state Each iteration Yes, per item

The general write-batching recipe that underpins the safe column is in How to batch DOM reads and writes to prevent thrashing, and the broader trigger list is in Reflow and Repaint Triggers.

Tracing It

In a Vue app the forced reflow attributes through Vue’s flush function rather than your handler, so read the bottom-up view down to the geometry getter:

[Main Thread] Task 17.4ms β€” DROPPED
└─ flushJobs (Vue scheduler)  (15.1ms)
   └─ watcher cb  updateList
      └─ Layout (9.6ms)  ⚠ Forced reflow
         └─ get scrollHeight  ← read before nextTick
Frame Budget: 16.6ms | Actual: 17.4ms

The full DevTools procedure for finding these markers is in Finding Layout Thrashing in DevTools.

Verification Checklist

Metric Target How measured
Forced reflow markers under flushJobs 0 Performance panel call tree
Geometry reads per state change 1, inside nextTick Code audit
INP on the interaction < 200ms Event Timing API / RUM
  • No getBoundingClientRect/offsetHeight reads inside computed
  • Watcher-driven measurements moved behind await nextTick()
  • ResizeObserver