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/offsetHeightreads insidecomputed - Watcher-driven measurements moved behind
await nextTick() -
ResizeObserver