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.

What one assignment triggers in Vue The chain of work Vue performs in one microtask flush after a handler mutates reactive state. What one assignment triggers in Vue Handler filters.color = 'red' Computed re-filter 5,000 items Watchers pre-flush callbacks Render patch large v-for Paint after the flush Steps two to four run in one microtask flush after the handler returns, so the browser cannot paint or handle input until all of them finish.

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, flushPreFlushCbs and componentUpdateFn in 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-for without stable :key values 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.

A Vue filter click on a 5,000-product catalogue Bar chart splitting a slow Vue filter interaction into handler, computed filtering, watcher work and list re-render. A Vue filter click on a 5,000-product catalogue Handler body 4ms Computed (deep reactive) 96ms Watchers 28ms v-for re-render (300 rows) 112ms 50ms task budget

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.

javascript
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.

javascript
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.

javascript
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.

vue
<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.

Handler shape before and after Comparison of a Vue click handler that triggers a single long flush with a restructured handler that paints a pending state and then applies chunked work. Handler shape before and after One long flush (240ms) • Assign filter value in handler • Deep computed re-filters 5,000 proxies • Watchers + full list patch • INP 260ms Paint first, chunk the rest • Show pending state, yield • Chunked filter over shallowRef data • v-memo rows, visible items only • INP 70ms, results 180ms later

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.