Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Use scheduler.yield() to keep the main thread responsive during long tasks

Break up tasks longer than 50 ms by yielding to the browser with scheduler.yield() or a MessageChannel fallback so that user input is never blocked.

Utilities
Quick take
Typical fix time 20 min
  • Tasks longer than 50 ms block input and hurt Interaction to Next Paint (INP)
  • scheduler.yield() pauses execution, lets the browser handle queued input, then resumes
  • Use a MessageChannel-based polyfill where scheduler.yield() is unavailable
  • Apply to data processing loops, tree traversals, and bulk DOM mutations
Why it matters: Any JavaScript task longer than 50 ms blocks the browser's main thread, preventing it from processing clicks, keyboard events, and rendering frames. Yielding between chunks of work keeps Interaction to Next Paint (INP) low and makes the page feel responsive even during heavy computation.

Rule Details

The browser's main thread handles JavaScript execution, style calculation, layout, paint, and user input on a single queue. A long-running task — even a fast-looking for loop over thousands of records — blocks that queue entirely. Until the task completes, click handlers, keyboard events, and animations are all frozen.

Code Example

scheduler.yield() is a Promise-based API that suspends the current task, allows the browser to handle any pending input or rendering work, and then resumes the suspended task. It is simpler and more semantically correct than setTimeout(callback, 0) because it preserves the task's priority in the scheduler queue.

// processInChunks.ts
 
/**
 * Yields to the browser between chunks of synchronous work.
 * Uses scheduler.yield() when available, with a MessageChannel fallback
 * for browsers that do not yet support the Scheduler API.
 */
function yieldToMain(): Promise<void> {
  // scheduler.yield() is available in Chrome 115+ and Edge 115+
  if ('scheduler' in globalThis && 'yield' in (globalThis as any).scheduler) {
    return (globalThis as any).scheduler.yield();
  }
 
  // MessageChannel fallback — faster than setTimeout(0) because it uses
  // a microtask-adjacent message port rather than a 4 ms clamped timer.
  return new Promise<void>((resolve) => {
    const { port1, port2 } = new MessageChannel();
    port1.onmessage = () => resolve();
    port2.postMessage(null);
  });
}
 
/**
 * Process a large array in chunks, yielding to the browser between each chunk.
 *
 * @param items      - The full array to process
 * @param processor  - Function applied to each item
 * @param chunkSize  - Number of items to process before yielding (default: 50)
 */
export async function processInChunks<T>(
  items: T[],
  processor: (item: T, index: number) => void,
  chunkSize = 50
): Promise<void> {
  for (let i = 0; i < items.length; i++) {
    processor(items[i], i);
 
    // Yield at the end of each chunk (not after every item — that would be too slow)
    if ((i + 1) % chunkSize === 0 && i + 1 < items.length) {
      await yieldToMain();
    }
  }
}

Why It Matters

Any JavaScript task longer than 50 ms blocks the browser's main thread, preventing it from processing clicks, keyboard events, and rendering frames. Yielding between chunks of work keeps Interaction to Next Paint (INP) low and makes the page feel responsive even during heavy computation.

The 50 ms Budget

The RAIL performance model (opens in new tab) and the INP metric both treat 50 ms as the budget for a single main-thread task. Tasks that exceed 50 ms appear in the Performance panel as "Long Tasks" (red bar) and directly contribute to poor INP scores.

Practical Usage

// ❌ Blocks the main thread for potentially hundreds of milliseconds
function renderSearchResults(results: SearchResult[]) {
  for (const result of results) {
    renderCard(result); // involves DOM mutation
  }
}
 
// ✅ Yields between chunks — input events are processed between chunks
async function renderSearchResults(results: SearchResult[]) {
  await processInChunks(results, (result) => renderCard(result), 25);
}
// ❌ Long synchronous data transformation
function buildReportData(transactions: Transaction[]): ReportRow[] {
  return transactions.map((t) => expensiveTransform(t));
}
 
// ✅ Yielding version that accumulates results
async function buildReportData(transactions: Transaction[]): Promise<ReportRow[]> {
  const results: ReportRow[] = [];
  await processInChunks(transactions, (t) => {
    results.push(expensiveTransform(t));
  });
  return results;
}

Choosing the Right Chunk Size

Chunk size is a balance between throughput and responsiveness:

  • Too large: tasks still block input; no improvement to INP
  • Too small: excessive yielding adds scheduling overhead

A starting point of 50 items per chunk works well for lightweight per-item work (e.g. DOM reads, object transformations). For heavier per-item work, reduce to 10–20. Measure in the Chrome DevTools Performance panel (opens in new tab) and cross-check the heuristics in web.dev's long task guidance (opens in new tab) to confirm tasks stay under 50 ms.

scheduler.yield() does not move work off the main thread

Yielding breaks one long task into many short tasks — it does not parallelize work. For truly CPU-intensive work (image processing, cryptography, complex calculations), prefer a Web Worker so the main thread is never involved at all.

Detecting Long Tasks in CI

Add a PerformanceObserver in development to log any task that exceeds 50 ms:

// lib/long-task-observer.ts (development only)
if (
  process.env.NODE_ENV === 'development' &&
  typeof PerformanceObserver !== 'undefined'
) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.duration > 50) {
        console.warn(
          `[LongTask] ${entry.duration.toFixed(1)} ms`,
          entry
        );
      }
    }
  });
  observer.observe({ type: 'longtask', buffered: true });
}

When to Use scheduler.yield() vs Web Workers

ScenarioRecommendation
Loop over 100–10 000 items with DOM side-effectsscheduler.yield() — DOM access requires main thread
Initialization logic that runs once on page loadscheduler.yield() — keeps UI interactive during startup
CPU-intensive computation (image processing, parsing)Web Worker — no DOM access needed
Streaming JSON parsing of large responsesWeb Worker + ReadableStream

Verification

Automated Checks

  • Open the Chrome DevTools Performance panel and record a page interaction that triggers the long task. Confirm tasks no longer appear as red "Long Task" markers.
  • Test the yieldToMain polyfill in Firefox (which does not support scheduler.yield()) to confirm the MessageChannel fallback fires correctly.
  • Set chunkSize to 1 in tests and verify the full array is processed correctly across multiple yields.

Manual Checks

  • Run a Lighthouse INP audit before and after applying processInChunks — score should improve.

Use with AI

Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.

Check

Verify implementation

Identify synchronous loops, recursive traversals, or bulk operations in this code that could run for more than 50 ms and block user input.

Fix

Auto-fix issues

Refactor long synchronous tasks to yield to the browser between chunks using scheduler.yield() with a MessageChannel fallback.

Explain

Learn more

Explain why tasks longer than 50 ms hurt INP, how scheduler.yield() works, and when to use it versus Web Workers.

Review

Code review

Review event handlers, data transformation functions, and initialization routines for synchronous loops over large collections that could block input. Flag any loop where the total work could exceed 50 ms.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

Chrome DevTools Performance paneldeveloper.chrome.comTool

Rules that often go hand-in-hand with this one.

Load scripts with defer, async, or type=module

Prevent JavaScript from blocking HTML parsing by using defer, async, or type=module attributes on script tags so the browser can continue building the DOM while scripts download.

HTML
Optimize interaction to next paint

Page responds to user interactions within 200ms, ensuring good responsiveness.

Performance
Implement proper error handling

Use try-catch blocks and error boundaries to gracefully handle errors in async operations and UI components.

JavaScript

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385