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.

Vue flush queue deduplicates writes Three synchronous state mutations queue one render effect that flushes on a microtask into a single DOM update. Synchronous mutations collapse into one flush state.a = 1 state.b = 2 state.c = 3 flush queue dedup: 1 render effect microtask 1 DOM patch 1 layout invalidation nextTick() resolves here

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.

Pending write plus geometry read forces layout A geometry read issued while a Vue render effect is still queued forces the browser to flush layout early. Read while a write is pending = forced reflow items.value = next render effect queued read scrollHeight layout tree still dirty forced Layout synchronous flush Fix: await nextTick() before reading write flushes first, then one clean read β€” no early layout

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.

Decision tree for safe geometry reads in Vue A decision tree routing where to place a geometry read so it never forces synchronous layout. Need to read layout geometry? is a reactive write pending? yes no await nextTick() then read once, post-flush source from an Observer ResizeObserver feeds a ref No forced reflow one Layout per reactive update

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:

Annotated flame chart of a dropped frame A timeline showing flushJobs enclosing a watcher callback whose geometry read triggers a forced layout that overruns the frame budget. Task 17.4ms β€” frame budget 16.6ms exceeded flushJobs (Vue scheduler) β€” 15.1ms watcher cb β€” updateList Layout 9.6ms β€” forced reflow get scrollHeight β€” read before nextTick bottom-up view attributes here
[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

Where Vue’s Async Flush Helps and Hurts

Vue batches DOM updates and flushes them asynchronously, which is normally a performance gift β€” many reactive changes collapse into a single DOM patch. The trap is reading geometry before that flush has happened: a getBoundingClientRect() inside a watcher that fired synchronously reads the stale pre-update layout, and if you then write based on that read you can trigger the exact thrash the framework’s batching was meant to avoid. The fix is to defer the read to nextTick(), which runs after the flushed DOM patch, so the measurement reflects the updated tree and no read-after-write interleave occurs within a frame. Understanding when the flush happens is what keeps Vue’s batching an asset rather than a hidden source of forced reflows.

Frequently Asked Questions

Does Vue's reactivity system prevent layout thrashing on its own?

Only for the writes Vue performs. The flush queue deduplicates render effects so a burst of state mutations produces one DOM patch and one layout invalidation. But any geometry read your own code issues β€” offsetHeight, getBoundingClientRect(), scrollHeight β€” while a render effect is still queued forces a synchronous layout regardless of Vue’s batching.

Why is reading offsetHeight inside a watcher a problem?

A watcher can fire mid-flush, before the render effects for the data it depends on have patched the DOM. Reading geometry there forces the browser to flush layout early so it can return an accurate value, and a subsequent write dirties layout again. Defer the read with await nextTick() so the DOM is fully patched and you read exactly once.

Can I put getBoundingClientRect inside a computed property?

No. A computed getter is meant to be a pure, cached derivation of reactive state. A layout read there forces reflow on every dependency access and returns a value the reactivity system cannot track, so the cache goes stale. Move the measurement to a ResizeObserver that feeds a plain ref, and let the computed derive from that ref.

Why does the forced reflow attribute to flushJobs instead of my handler?

Vue drains its flush queue inside its scheduler function (shown as flushJobs in the flame chart). Your watcher or update callback runs as a child of that function, so the Layout event and its forced-reflow marker nest under the scheduler rather than your event handler. Read the bottom-up call tree down to the geometry getter to find which read triggered it.

What replaces a resize-listener that reads geometry?

A ResizeObserver. It reports element dimensions after layout has already settled, so writing those dimensions into a ref never forces a synchronous flush. This keeps your computed properties pure and moves measurement off the reactive write path entirely.