Yielding to the Main Thread During Animations

Part of Main-Thread Scheduling & Long Animation Frames in Performance Budgeting & GPU Architecture.

The problem

A user taps “Filter”. The design calls for the filter sheet to slide up while the product grid re-renders underneath. On a fast laptop both happen instantly. On a mid-range phone the tap appears to do nothing for a third of a second, then the sheet is simply there — its entrance animation either skipped or compressed into two frames — and the grid pops in at the same moment.

The animation code is not the problem. The sheet uses a transform transition that would run on the compositor. It never gets the chance, because the click handler filters 2,000 products and rebuilds the grid before returning, and the style change that starts the transition is not processed until the handler ends.

Root cause: one task, no rendering opportunity

The browser renders between tasks, never inside one. Everything the click handler does — setting the class that opens the sheet, filtering data, creating DOM nodes — is one task. The class is set in the first millisecond, but the browser cannot compute style for it, start the transition or present a frame until the last line runs.

Microtasks do not help. await Promise.resolve() or a .then() continuation runs in the microtask checkpoint immediately after the current task, before rendering, so splitting work across promises still produces one uninterrupted block of main-thread time.

What helps is ending the task. A real yield — a new task, scheduled so that rendering can happen first — lets the browser process the class change, start the compositor animation and present its first frame. Once the animation is on the compositor, the heavy work that follows can take as long as it needs without freezing the sheet.

The filter tap, without and with a yield after the visual changeThe total work is identical; the yield moves the first animated frame from 330 ms to 14 ms.The filter tap, without and with a yield after the visual changeframe budget 50 msSynchronous handlerfilter + render grid330 msVisual first, then yieldchunkchunkremaining chunks350 msset classfilter + render gridfirst framechunkframeremaining chunks
The total work is identical; the yield moves the first animated frame from 330 ms to 14 ms.

Step-by-step resolution

Restructuring a handler around its animationThe rule is visual state, yield, work — never work, then visual state.Restructuring a handler around its animation1Move the class, attribute or style change that starts the animation to the top of the handler.The animation is requested before any heavy work2Update accessible state there too: aria-expanded, focus, aria-busy.Assistive technology is not delayed by the work3await scheduler.yield() immediately after.The browser renders the first animated frame4Run the heavy loop in chunks, yielding when a chunk passes about 8 ms.No task exceeds the long-task threshold5Move analytics and cache writes to postTask with background priority.They wait behind rendering and input6Ship a fallback yield for engines without scheduler.yield.
The rule is visual state, yield, work — never work, then visual state.

Time-based chunks are more robust than count-based ones. Forty rows might take 4ms on a laptop and 30ms on a low-end phone; checking elapsed time with performance.now() adapts the chunk size to the device automatically.

Production code pattern

// A yield that uses the platform API where present and a fast task otherwise.
const yieldToMain = globalThis.scheduler?.yield
  ? () => scheduler.yield()
  : () => new Promise((resolve) => {
      const { port1, port2 } = new MessageChannel();   // a new task, without timer clamping
      port1.onmessage = () => resolve();
      port2.postMessage(null);
    });

const background = (fn) =>
  globalThis.scheduler?.postTask
    ? scheduler.postTask(fn, { priority: 'background' })
    : setTimeout(fn, 0);

filterButton.addEventListener('click', async () => {
  // 1. Visual and accessible state first.
  sheet.classList.add('is-open');
  filterButton.setAttribute('aria-expanded', 'true');
  grid.setAttribute('aria-busy', 'true');

  // 2. Let the browser render the sheet's first frame.
  await yieldToMain();

  // 3. Heavy work in time-sliced chunks.
  const results = [];
  let sliceStart = performance.now();
  for (const product of products) {
    if (matches(product, activeFilters)) results.push(product);
    if (performance.now() - sliceStart > 8) {
      await yieldToMain();
      sliceStart = performance.now();
    }
  }
  await renderGridInChunks(results);
  grid.setAttribute('aria-busy', 'false');

  // 4. Nothing the user sees.
  background(() => track('filter_applied', activeFilters));
});
.sheet {
  transform: translateY(100%);
  transition: transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1);  /* compositor-only */
}
.sheet.is-open { transform: translateY(0); }

@media (prefers-reduced-motion: reduce) {
  .sheet { transition: none; }
}

Rendering Impact: composite for the sheet, main-thread in bounded chunks for the grid. Once the transition has started on the compositor, the chunks can run without affecting its smoothness; the chunk size keeps input responsive during the work.

The MessageChannel fallback posts a message to itself, which queues a new task without the 4ms clamping nested setTimeout calls receive. It does not give the continuation priority the way scheduler.yield() does, so other queued tasks may run first — acceptable for a fallback.

Longest task during the filter interactionMid-range phone, 2,000 products. Chunking at 8 ms keeps every task well under the long-task threshold.Longest task during the filter interactionbudget 50 msSynchronous handler331 msPromise.then between steps329 mssetTimeout(0) per 200 items61 msscheduler.yield() per 8 ms11 ms
Mid-range phone, 2,000 products. Chunking at 8 ms keeps every task well under the long-task threshold.

Verification checklist

Constraints and trade-offs

  • Yielding lets other code run between chunks; state that the handler depends on can change, so re-check preconditions after each yield if they matter.
  • A second click during the work starts a second run; cancel the first with an AbortController or ignore stale results.
  • Chunking adds a little total time; the win is responsiveness and animation smoothness, not throughput.
  • Yielding does not help animations that run on the main thread every frame — a layout-property transition still stalls during each chunk. Keep the animation compositor-only.
  • For truly heavy computation, a Web Worker removes the work from the main thread entirely and is better than any amount of yielding.

Frequently asked questions

Why does await Promise.resolve() not let the animation start?

Promise continuations are microtasks, and the microtask queue is drained before the browser is allowed to render. The animation’s style change is still waiting when the continuation runs. A real yield has to create a new task.

How large should each chunk be?

Aim for well under 50ms on the slowest device you support. Time-slicing at around 5 to 10ms, checked with performance.now(), adapts to the device and leaves room for rendering and input in each frame.

Is requestAnimationFrame a good way to yield?

It yields, but it waits for the next frame and runs your work just before rendering, which can push that frame late. Use it for visual writes, and scheduler.yield() or a task-based fallback for chunking non-visual work.

When should I use a Web Worker instead?

When the work is pure computation — filtering, sorting, parsing, diffing — and does not need the DOM. A worker removes it from the main thread, so animations and input are unaffected no matter how long it runs. DOM updates with the results still happen on the main thread and may still need chunking.