cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Keeping the main thread responsive

Break up long JavaScript tasks with scheduler.yield, requestIdleCallback, chunking, and Web Workers so input and painting stay responsive.

By the end you can
  • 01
    Explain why pages freezeConnect long tasks to input delay, processing time, presentation delay, and INP.
  • 02
    Break work into fair slicesUse a real chunking helper with injected clocks and yielding strategies.
  • 03
    Choose the right escape hatchCompare scheduler.yield, timers, idle callbacks, animation frames, and workers.

The responsive thread

The browser main thread is where most page JavaScript runs. It is also where the page coordinates input events, style recalculation, layout, paint, and DOM updates. While one JavaScript task is running, the thread cannot handle another click, accept typing into an input, recalculate layout, or paint the next frame.

Plain definition: keeping the main thread responsive means making every user-visible task short enough that input and rendering get frequent turns. When work is too large for one turn, split it into chunks, schedule it for idle time, or move CPU-heavy data work to a Web Worker.

Real-life analogyOne cashier and a long queue

Imagine a shop with one cashier. If one customer unloads a huge cart and the cashier insists on scanning all of it without pausing, the next customer cannot ask a question, the card reader cannot be handled, and the receipt printer waits. A better cashier scans a small batch, checks for waiting customers, then continues.

In real life: The only cashier
In JavaScript: The browser main thread
In real life: Scanning one huge cart
In JavaScript: A long JavaScript task
In real life: Card taps and questions waiting
In JavaScript: Clicks, typing, style, layout, and paint
In real life: Sending boxes to the stockroom
In JavaScript: Moving CPU-heavy data work to a worker

Where the analogy stops: A real store can open another register for customers. A page can add workers for data work, but DOM, layout, and most input handling still come back to the main thread.

This lesson builds on The event loop, Microtasks in depth, Timers, and Web Workers. It also links to Animation, Typed arrays, Measuring performance, and the next Debounce & throttle lesson.

Long tasks and INP

MEASURE FIRST

A long task is a main-thread task that runs for more than 50 ms. The threshold is intentionally larger than one animation frame: it marks work that is likely to be noticed or to block input on slower devices. The RAIL mental model is practical here: users expect input feedback quickly, animation frames have tiny budgets, idle work should be opportunistic, and loading should not steal interaction time.

The 50 ms line is a warning, not a goal

A 49 ms task can still miss frames, and several medium tasks in a row can feel bad. Treat 50 ms as the reporting line for long tasks, then aim much smaller for work that happens during active input.

Interaction to Next Paint phases
PhaseWhat waitsWhat can improve it
Input delayTime from user input until the event handler startsKeep the main thread free; avoid long tasks before handlers.
Processing timeThe event handler and related JavaScript workChunk, debounce, throttle, or move CPU-heavy data work.
Presentation delayTime from finished handler until the next painted feedbackAvoid forced layout and schedule visual work with frames.

Interaction to Next Paint replaced First Input Delay as a Core Web Vital in March 2024. A good INP is 200 ms or less at the 75th percentile of real user visits. That percentile matters: one fast development laptop does not prove field users are safe.

Observe long tasks and Long Animation FramesPop out in the code editor (opens in a new tab)JavaScript
const observer = new PerformanceObserver((list) => {  for (const entry of list.getEntries()) {    console.log(entry.entryType, Math.round(entry.duration));  }}); observer.observe({ type: "longtask", buffered: true });observer.observe({ type: "long-animation-frame", buffered: true });

PerformanceObserver can report longtask entries, and Chromium browsers also expose Long Animation Frame entries as long-animation-frame. Use these signals with DevTools and real-user monitoring from the Measuring performance lesson.

Yielding APIs

API CHOICES

Yielding means stopping your current slice of work and letting the browser take other work before you continue. A task boundary can let input and paint happen; a microtask boundary usually cannot. The exact API you choose controls support, priority, and how quickly your continuation resumes.

Feature-detected scheduler.yieldPop out in the code editor (opens in a new tab)JavaScript
async function yieldToMainThread() {  if (globalThis.scheduler?.yield) {    await globalThis.scheduler.yield();    return "scheduler.yield";  }  await new Promise((resolve) => setTimeout(resolve, 0));  return "setTimeout fallback";} yieldToMainThread().then((mode) => {  console.log("yielded with " + mode);});

scheduler.yield() is the modern shape for pausing an async function and continuing soon with its priority. Chrome added it in version 129, newer Firefox versions support it, and stable Safari does not support it at the time of writing. That is why production code feature-detects and keeps a setTimeout fallback.

scheduler.postTask with a timer fallbackPop out in the code editor (opens in a new tab)JavaScript
const schedulerApi = globalThis.scheduler; if (schedulerApi?.postTask) {  schedulerApi.postTask(    () => console.log("background task"),    { priority: "background" },  );} else {  setTimeout(() => console.log("fallback task"), 0);}
Ways to stop monopolizing the main thread
ToolWhat it doesBest forCaution
setTimeout(fn, 0)Queues a later taskUniversal fallback that yields to input and paintNested timers face the HTML 4 ms clamp after several levels.
MessageChannelPosts a message task without timer delayLibrary-level task queues when you need broad supportLess ergonomic and no priority model.
scheduler.postTaskQueues a prioritized taskPrioritizing user-blocking, visible, or background tasksChromium-first; always feature-detect.
scheduler.yield()Pauses the current async task and continues later with priorityBreaking long async work while preserving continuation priorityChrome 129+, Firefox newer, no Safari stable support; use a fallback.
requestIdleCallbackRuns when the browser is idle, with a deadlineNon-urgent cleanup, analytics, cache warmupsNot for urgent work and not supported in stable Safari.
Web WorkerRuns JavaScript on another threadCPU-heavy data work with plain input and outputNo DOM access, startup cost, and message/clone cost.

setTimeout(fn, 0) is the universal fallback because it queues a later task. The Timers lesson covers the catch: after several nested timers, browsers clamp the minimum delay to about 4 ms. Some schedulers use MessageChannel to avoid timer clamping, and the Scheduler API adds priorities where it is available.

Chunking work

STEP THROUGH

Chunking is the practical pattern for large arrays: do a small slice, yield, then continue. The helper below is a real function from this lesson. It accepts a time budget, a clock, and a yield function. The clock and yield are injectable so tests and the step-through can be deterministic.

Reusable processInChunks helperPop out in the code editor (opens in a new tab)JavaScript
async function processInChunks(items, work, options = {}) {  const budgetMs = Math.max(1, options.budgetMs ?? 8);  const clock = options.clock ?? performance;  const yieldFn = options.yieldFn ?? yieldToMainThread;  let index = 0;   while (index < items.length) {    const started = clock.now();    do {      work(items[index], index);      index += 1;    } while (index < items.length && clock.now() - started < budgetMs);     if (index < items.length) {      await yieldFn();    }  }}
Step through chunked array work
Step 0 of 13Ready
Your turn: follow the blue line

Step through a deterministic run of the real processInChunks helper. The fake clock and fake yield make the order testable without waiting.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  { label: "A", cost: 2 },  { label: "B", cost: 3 },  { label: "C", cost: 2 },  { label: "D", cost: 4 },  { label: "E", cost: 1 },]; await processInChunks(jobs, (job) => {  doWork(job.label);  clock.advance(job.cost);}, {  budgetMs: 5,  clock,  yieldFn: () => yieldToBrowser(),});
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The replay uses jobs A through E with fake costs. A and B exactly fill the first 5 ms budget. C and D overrun the next budget slightly because a chunk finishes the current item before yielding. E finishes the final chunk, so no last yield is necessary.

Step through why promises do not yield
Step 0 of 10Ready
Your turn: follow the blue line

This bounded promise loop proves the concept: microtasks run before the timer, so chaining promises is not a safe way to yield to rendering.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
let count = 0;function scheduleNext() {  count += 1;  console.log("microtask " + count);  if (count < 3) {    Promise.resolve().then(scheduleNext);  }}Promise.resolve().then(scheduleNext);console.log("script done");
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The bounded promise loop proves an important misconception from the Microtasks lesson: a promise chain is not a paint break. Microtasks drain before rendering, so long work must yield with a task-like boundary or leave the main thread.

Idle and frame work

NOT URGENT

requestIdleCallback asks the browser to run work when it has spare time. Its callback receives a deadline object, and deadline.timeRemaining() tells you how much idle time is left. Always pass a timeout for work that eventually matters, and always feature-detect because stable Safari does not support it by default.

Idle work with deadline and fallbackPop out in the code editor (opens in a new tab)JavaScript
function scheduleIdleFlush(queue) {  const run = (deadline) => {    while (deadline.timeRemaining() > 0 && queue.length > 0) {      sendAnalytics(queue.shift());    }    if (queue.length > 0) scheduleIdleFlush(queue);  };   if ("requestIdleCallback" in window) {    return requestIdleCallback(run, { timeout: 2000 });  }   return setTimeout(() => run({ timeRemaining: () => 3 }), 1);}
Idle does not mean urgent

Do not put pressed states, validation feedback, or visible loading indicators behind requestIdleCallback. Idle work is for non-urgent cleanup, analytics, cache warmups, and maintenance that can wait behind input and paint.

requestAnimationFrame has a different job. It runs before a paint, so it is for visual work such as reading animation state, writing transforms, or drawing a canvas frame. The Animation lesson covers that frame loop in depth.

Moving work to workers

ANOTHER THREAD

Chunking still uses the main thread. If the job is CPU-heavy and can be described as input data plus output data, a worker may be the better home. Workers cannot read layout or update DOM, but they can parse, search, sort, compress, and process bytes while the UI thread keeps responding.

Worker boundary for data workJavaScript
// main threadconst worker = new Worker(workerUrl);worker.postMessage({ type: "filter", query: "Ada", rows });worker.onmessage = (event) => {  renderRows(event.data.matches);}; // worker.jsself.onmessage = (event) => {  const { query, rows } = event.data;  const matches = rows.filter((row) => row.name.includes(query));  self.postMessage({ matches });};
Worker trade-offs
QuestionGood signWarning sign
Is the work CPU-heavy?Sorting 100,000 rows, parsing a large export, image processingFormatting a few labels or six dates
Can the data cross the boundary cheaply?Plain objects, arrays, or transferable ArrayBuffer valuesHuge object graphs copied every keystroke
Does it need the DOM?No DOM access; main thread renders the replyRequires document, layout reads, focus, or direct React state
Can transferables help?Typed-array bytes can move ownershipBoth sides need the same buffer after sending
Can OffscreenCanvas help?Canvas pixel work can move off thread when supportedDOM layout and ordinary element painting still stay main
Would RPC help?Comlink-style wrappers make message replies feel like async functionsThe underlying cost is still messages and clones

The Web Workers lesson covers lifecycle, structured clone, transferables, module workers, and errors. The Typed arrays lesson explains the byte buffers that often make worker transfers worthwhile.

Responsiveness lab

FEEL THE DIFFERENCE

The lab below keeps the CPU work bounded to about half a second per run. Watch the spinner and counter while typing in the input. Blocking mode runs one long task. Chunked mode does the same total work in slices with yields. Worker mode runs the CPU loop in a real Blob URL worker and sends a reply back.

Responsiveness lab: blocking, chunked, or worker
Three homes for heavy workPop out in the code editor (opens in a new tab)JavaScript
const modes = ["blocking", "chunked", "worker"]; // Blocking: one long task. Input, paint, and timers wait.runButton.onclick = () => doCpuWork(450); // Chunked: same total work, but yield between slices.await processInChunks(items, work, {  budgetMs: 24,  yieldFn: yieldToMainThread,}); // Worker: CPU work happens on another thread.worker.postMessage({ ms: 450 });
Live UIidle

Counter: 0

Input value: empty

  • Try typing while each mode runs. Blocking pauses the counter; chunked and worker modes keep giving the page turns.
Try it yourself

Pick a mode. The same bounded CPU budget is shown as one long task, chunked slices, or worker work.

The CPU work is capped around half a second so the lab teaches responsiveness without hanging the tab. Durations vary by device.

Practical use

Main-thread responsiveness work is common in production. Search pages filter large lists, admin tools parse export files, image editors process pixels, dashboards batch analytics, and code editors highlight many lines. The right tool depends on urgency, DOM access, data size, and whether the work is already asynchronous.

Real-world patterns
ScenarioLikely choiceReason
Search filtering a big local listChunk or workerChunk if rendering partial results matters; worker if CPU dominates.
Parsing a large JSON exportWorkerParsing and transforming can block input, and the output is data.
Image processingWorker plus transferablesMove bytes with ArrayBuffer; consider OffscreenCanvas for canvas pipelines.
Analytics batchingIdle callbackNon-urgent work can wait for spare time with a timeout fallback.
Drag feedback and animationMain thread with requestAnimationFrameVisual writes need frame alignment and DOM access.
Main thread, chunk, idle, or worker?
  • Change a button label after click
  • Filter 30,000 visible search results
  • Send queued analytics beacons
  • Process megapixel image bytes
  • Read getBoundingClientRect() and update classes
  • Parse a 25 MB JSON export
  • Warm a small in-memory cache after load
  • Highlight 2,000 code lines
Try it yourself
0 of 8 correct

Sort each job by the first home you would consider. Then read the explanation for the trade-off.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Misconceptions

“A promise loop yields to paint.”

Promises use microtasks. Microtasks drain before rendering, so a long chain still blocks the page.

“requestIdleCallback is a faster setTimeout.”

Idle callbacks are for optional work. They may not run soon, and Safari needs a fallback.

“Workers fix every performance problem.”

Workers cannot touch DOM, and startup plus message cost can dominate tiny work.

“INP is only the handler time.”

INP includes input delay, processing time, and presentation delay until the next paint.

“isInputPending is the modern answer.”

navigator.scheduling.isInputPending() is deprecated. Prefer measured chunking and scheduler APIs with fallbacks.

Spinner bug: promise is not a paint breakJavaScript
button.addEventListener("click", () => {  spinner.hidden = false;  Promise.resolve().then(() => {    expensiveLoop();  });});

The code changes the spinner state, but the expensive loop is placed in a microtask. The browser still drains that microtask before painting, so the spinner may not become visible until after the work is done.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict the queue order

Type the output order with spaces or commas.

Starter codePop out in the code editor (opens in a new tab)JavaScript
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("microtask"));
console.log("sync");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeName the INP phases

    Type the three phases in order.

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRead the deterministic chunk run

      How many chunks and yields does the replay use?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const items = ["A", "B", "C", "D", "E"];
      // Budget groups them as [A,B], [C,D], [E].
      console.log("chunks: 3");
      console.log("yields: 2");

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeChoose idle or urgent

        Should urgent input feedback, such as a pressed button state, be scheduled with requestIdleCallback?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengePlace DOM work on the right side

          Can the worker directly update the DOM after sorting rows?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          // main thread
          worker.postMessage({ rows });
          worker.onmessage = (event) => renderRows(event.data);
          
          // worker
          self.onmessage = (event) => {
            self.postMessage(sortRows(event.data.rows));
          };

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz: check your understanding

            7 QUESTIONS
            Main thread quiz · 7 questionsScore: first tries count
            1. Question 1 of 7What is a long task in the browser performance model?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              setTimeout(() => console.log("timer"), 0);
              Promise.resolve().then(() => console.log("microtask"));
              console.log("sync");

              Choose an answer to see the explanation.

            3. Question 3 of 7Which statement about scheduler.yield() is most accurate?

              Choose an answer to see the explanation.

            4. Question 4 of 7Why does a promise loop still freeze the page?

              Choose an answer to see the explanation.

            5. Question 5 of 7Which work belongs in requestIdleCallback?

              Choose an answer to see the explanation.

            6. Question 6 of 7When does a worker not help?

              Choose an answer to see the explanation.

            7. Question 7 of 7What are the parts of INP?

              Choose an answer to see the explanation.

            Key takeaways

            • The main thread runs JavaScript, input handlers, style, layout, and paint coordination.
            • Long tasks over 50 ms can delay input and hurt INP; good INP is at most 200 ms at p75.
            • Microtasks do not yield to rendering; promise loops can still freeze the page.
            • Chunk arrays with a real budget and a feature-detected yield strategy.
            • Use requestIdleCallback only for non-urgent work, with a timeout and fallback.
            • Move CPU-heavy data work to workers when clone, transfer, and startup costs are worth it.

            Final definition.
            A responsive main thread is one that keeps user-visible tasks short, yields between slices, and sends heavy data work away from the UI when that is the better trade-off.

            Up next: Debounce & throttle, where you will stop expensive handlers from starting too often in the first place.

            CompleteFrontend Clear concepts. Working examples.