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 |
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.
Related Guides
- Forced Synchronous Layouts β the parent concept: how a read-after-write forces the browser to flush layout mid-frame.
- Finding Layout Thrashing in DevTools β the full Performance-panel procedure for spotting forced-reflow markers.
- How to batch DOM reads and writes to prevent thrashing β the read/write batching recipe behind the safe patterns above.
- Reflow and Repaint Triggers β the broader catalogue of operations that invalidate layout.