How to Break Up Long Tasks in Vue Event Handlers
This guide is the Vue counterpart to breaking up long tasks in React event handlers, within Optimizing INP with scheduler.yield() and Core Web Vitals & Measurement. Vue's reactivity system makes state changes feel cheap — assign a value and the UI updates — which hides how much work a single assignment can trigger.
When a handler mutates reactive state, Vue queues every affected computed property, watcher and component render, then flushes the queue in a microtask after the handler returns. All of that runs before the browser can paint. A click that sets one filter value can recompute a computed over 5,000 items, run several watchers that call APIs, and re-render a large list — one long task from the browser's perspective, even if the handler itself is three lines.
Rapid Diagnosis
- Profile with Vue DevTools' Performance tab (or the Timeline) while performing the interaction. Note component render times and how many components re-rendered.
- In the Chrome Performance panel, expand the interaction's long task: look for
flushJobs,flushPreFlushCbsandcomponentUpdateFnin the call tree, and the heaviest computed getters below them. - Count reactive dependencies. A computed that iterates a deep reactive array tracks every element's every accessed property; reading it after a change re-runs everything.
- Check for deep watchers.
watch(source, cb, { deep: true })on large objects traverses the whole structure on every change. - Check list keys. A
v-forwithout stable:keyvalues re-creates rows instead of patching them.
Root Cause Analysis
1. Expensive computed properties on large reactive data. Deep reactivity wraps every nested object in a proxy; filtering and sorting through proxies is markedly slower than through plain arrays.
2. Deep watchers and watcher chains. One change triggers a watcher that mutates more state, triggering more watchers — all inside the same flush.
3. Large list re-renders. A v-for over hundreds of items re-renders when its source array is replaced, even if most items are unchanged.
4. Synchronous work in the handler itself. Building payloads, validating forms or transforming data before mutating state.
Step-by-Step Resolution
1. Store large datasets with shallowRef
Large arrays of records that you replace rather than mutate in place should not be deeply reactive.
import { shallowRef, computed } from 'vue';
const products = shallowRef([]); // only .value replacement is tracked
const filtered = computed(() =>
products.value.filter((p) => p.color === filters.color && p.price <= filters.maxPrice));
// When data changes, replace the array: products.value = newArray
// trade-off: in-place mutations (products.value.push(x)) do not trigger
// updates with shallowRef. Replace the array, or call triggerRef(products).
Expected outcome: computed filtering over plain objects runs several times faster; on large catalogues, tens of milliseconds saved. See cutting Vue reactivity cost with shallowRef.
2. Yield before the expensive state change
Let the browser paint the pressed state and handle pending input before triggering the heavy flush.
const yieldToMain = () =>
globalThis.scheduler?.yield?.() ?? new Promise((r) => setTimeout(r, 0));
async function onApplyFilters() {
applying.value = true; // cheap: shows a spinner on the button
await yieldToMain(); // paint happens here
filters.color = pendingColor.value; // heavy reactive flush in the next task
await nextTick();
applying.value = false;
}
// trade-off: the results now update in a task after the interaction's paint,
// so INP measures only the cheap first part. That is the goal — but make sure
// the pending state is visible so users do not click again.
Expected outcome: the interaction's processing time falls to the cost of the first state change; the heavy flush no longer counts towards INP for that click.
3. Chunk large recomputations
When the derived data is genuinely expensive, compute it in chunks outside the reactive system and assign the result once.
async function recompute(all, predicate) {
const out = [];
let deadline = performance.now() + 40;
for (const p of all) {
if (predicate(p)) out.push(p);
if (performance.now() > deadline) { await yieldToMain(); deadline = performance.now() + 40; }
}
results.value = out; // single reactive assignment
}
// trade-off: computed properties cannot be async, so chunked derivations live
// in functions you call explicitly. You lose automatic dependency tracking for
// that value; call recompute() from a watcher on the inputs instead.
Expected outcome: no task exceeds 50ms during recomputation.
4. Reduce what re-renders
Use stable keys, v-memo for rows whose inputs are unchanged, and render only visible rows for long lists.
<div v-for="p in visibleProducts" :key="p.id" v-memo="[p.id, p.price, selectedId === p.id]">
<ProductRow :product="p" :selected="selectedId === p.id" />
</div>
<!-- trade-off: v-memo skips updates unless a listed dependency changes. Forget a
dependency and the row shows stale data — list every value the row reads. -->
Expected outcome: the re-render touches only changed rows; on lists of hundreds of items this is often a 3–10x reduction in render time.
Verification
Record the interaction again with 4x CPU throttling. The interaction's long task should be gone; instead you should see a short task for the click, a paint, then several sub-50ms tasks for the recomputation and render. In Vue DevTools, the number of components re-rendered should drop. In RUM, INP attribution for the filter control should show processing duration well below 100ms.
Vue-Specific Traps When Yielding
Reading reactive state across an await. After await yieldToMain(), other code may have changed state. Re-read what you need after the yield instead of relying on values captured before it.
watchEffect with async bodies. Dependencies are only tracked before the first await. Code after a yield inside a watchEffect will not re-run when its dependencies change. Keep tracked reads synchronous at the top.
nextTick is not a yield. nextTick() waits for Vue's own flush, which happens in a microtask — the browser cannot paint or handle input in between. Use a real task boundary (scheduler.yield() or setTimeout) when the goal is to let the browser breathe.
Pinia actions. Store actions often contain the expensive logic. They can be async and yield like any function; make sure components awaiting them render a pending state rather than blocking on the result.
Measuring Vue Flush Cost in the Field
Vue does not expose flush timings to RUM by default, but you can approximate them. Wrap expensive actions with performance.mark() before the state change and after await nextTick(); the measure between them covers the reactive flush and DOM patch. Send it with your INP beacon for the matching interaction target. Over a week this tells you which components' updates dominate processing time on real devices, which is far more reliable than guessing from a desktop profile. Combine it with the web-vitals attribution build's processingDuration to confirm the flush is the part of the interaction that matters.
FAQ
Does Vue batch multiple state changes in one handler?
Yes. All changes made synchronously in a handler are flushed together after it returns, so ten assignments cost one render pass, not ten. That is good for total work but means the whole cost lands in one task. Yielding between groups of changes deliberately splits that pass into several.
Is the Vapor mode or a compiler change going to fix this?
Compiler and runtime improvements reduce per-component overhead, which lowers render cost. They cannot make filtering 5,000 records free or decide which work is urgent. Narrowing reactivity, rendering less and yielding remain necessary for large data regardless of rendering mode.
Should I move this work to a Web Worker instead?
For pure data processing — filtering, sorting, aggregation — yes, a worker removes it from the main thread entirely, and only the result is assigned to reactive state. Rendering cannot move to a worker, so steps 2 and 4 still apply.
Related
- Vue & Nuxt performance — the framework-level patterns behind these fixes.
- Using scheduler.postTask() priorities — ordering the follow-up work.
- Reducing presentation delay from large DOM updates — the rendering cost after the flush.