10 min read

The Main Thread and Scheduling

One thread runs your code, handles input and draws frames. Give it work in small pieces, or everything waits.


The story

An operations dashboard has a filter box above a table of 20,000 shipments. On every keystroke the app filters the array, sorts the result, and re-renders the table.

On the developer's laptop it feels fine. On the warehouse team's mid-range Android tablets, typing "mumbai" goes like this: the "m" appears after a pause, then nothing, then "umbai" arrives all at once. Field data reports an INP of 480 ms on that page. Users describe the app as "frozen".

The profile shows why. Each keystroke starts a single task that runs for about 300 ms: filter (40 ms), sort (60 ms), React render and commit of thousands of rows (200 ms). While that task runs, the browser can't do anything else. It can't paint the letter that was typed, run the next key event or update the caret. The keystrokes queue up behind the task, and when it finally ends they are processed in a burst.

The code wasn't wrong. It was one big piece of work on a thread that also has to respond to the user. This chapter is about splitting that work up so the browser gets a turn, and Chapters 3 and 4 are about doing less of it in the first place.


Part 1: What the main thread actually does

The browser's main thread runs an event loop: take a task, run it to completion, then possibly render a frame, then take the next task.

The key word is completion. Once a task starts, nothing interrupts it: not input, not rendering, not other tasks. A task that runs for 300 ms means 300 ms with no frames and no response to input, however urgent.

The frame budget

A 60 Hz display shows a new frame every 16.7 ms. A 120 Hz display, common on newer phones, every 8.3 ms. The browser needs part of each frame for its own work (style, layout, paint, compositing), so for smooth animation or scrolling your JavaScript gets roughly 10 ms per frame, less at 120 Hz.

For responses to input, the standard is looser but still tight: the next frame after a click should appear within 100 ms to feel instant, and INP counts an interaction as "good" if that happens within 200 ms.

Long tasks

The browser defines a long task as any task over 50 ms. That number comes from the 100 ms response target: if no task lasts longer than 50 ms, input that arrives in the middle of one waits at most 50 ms, leaving 50 ms to handle it and render.


Part 2: How a slow interaction is made

INP measures the time from a user's input to the next frame that shows its result. It has three parts, and each needs a different fix:

PartCaused byFix
Input delayA long task already running when the input arrives (hydration, a timer, a previous keystroke's work)Break long tasks up. Move work off the main thread.
Processing timeYour handlers doing too muchDo the visible part first, defer the rest
Presentation delayA huge render: many DOM nodes, forced layouts, big paintsRender less (virtualization), avoid layout thrashing

The story's dashboard had all three: the previous keystroke's task caused input delay, the filter and sort were processing time, and rendering 20,000 rows was presentation delay.


Part 3: Microtasks don't give the browser a turn

A common misunderstanding: "I made the function async, so it won't block." It still does.

js
async function processAll(items) {
  for (const item of items) {
    await handle(item)   // if handle() resolves immediately, this never yields
  }
}

Promise callbacks are microtasks, and the event loop runs all queued microtasks before it moves on, including ones added while the queue is being drained. A loop of resolved awaits is still one uninterrupted block of work as far as rendering and input are concerned. Only awaiting something that really completes later (a network response, a timer, a real yield) lets the browser in.

The event loop chapter in the JS section covers the full task/microtask model. Its simulation shows the queues directly.


Part 4: The scheduling tools

APIRuns whenUse for
requestAnimationFrame(cb)Just before the next frame's style/layoutVisual updates: animations, applying batched DOM changes, reading scroll position once per frame
setTimeout(cb, 0)As a new task, at the back of the queueOld-style yielding. Browsers clamp it to ≥4 ms after 5 nested calls.
MessageChannel postAs a new task, without the clampFast yielding fallback (React's scheduler uses this)
requestIdleCallback(cb)When the browser is idle, with a deadlineTruly optional work: analytics, prefetching. Not supported in Safari.
scheduler.postTask(cb, {priority})As a task at user-blocking, user-visible or background priorityPrioritised background work (Chromium and Firefox)
scheduler.yield()Pauses your async function and resumes it at the front of the queueBreaking up a long task without losing your place to unrelated tasks

Yielding, properly

scheduler.yield() is the most direct tool. It lets pending input and rendering happen, then continues your work ahead of other queued tasks. According to web.dev it's available in Chrome and Edge from version 129 and Firefox from 142, but not in Safari, so ship a fallback:

js
function yieldToMain() {
  if (globalThis.scheduler?.yield) return scheduler.yield()
  return new Promise((resolve) => {
    const { port1, port2 } = new MessageChannel()
    port1.onmessage = () => resolve()
    port2.postMessage(null)
  })
}

One catch: input isn't the same as frames. scheduler.yield() puts your continuation ahead of other work, so pending input gets handled, but the browser may not paint a new frame between your slices. When testing this chapter's simulation in Chrome, a 400 ms loop yielding with scheduler.yield() painted roughly one frame every 100 ms (a 118 ms longest gap), while the same loop yielding through MessageChannel painted every frame (17 ms) and setTimeout also painted every frame but took 50% longer because of the clamp. If you need frames to keep flowing too (an animation, a progress bar), yield with an ordinary task (MessageChannel, or setTimeout if the 4 ms clamp is acceptable), or move the work to a worker. Measure it on your target browsers: this is scheduler behaviour, and it can change between versions.

Time-slicing a big loop

Yielding after every item is wasteful. Yielding after every N items is fragile, because N depends on the device. Yield when a time budget runs out:

js
async function filterInSlices(rows, predicate, budgetMs = 8) {
  const out = []
  let deadline = performance.now() + budgetMs
  for (let i = 0; i < rows.length; i++) {
    if (predicate(rows[i])) out.push(rows[i])
    if (performance.now() > deadline) {
      await yieldToMain()             // input and a frame can happen here
      deadline = performance.now() + budgetMs
    }
  }
  return out
}

The total work takes slightly longer when sliced. But the page stays responsive throughout, and that is what users notice and INP measures.

Cancel stale work

In the story, each keystroke started a fresh filter while the previous one was still running. Slicing makes it possible to abandon stale work: check a version number or an AbortSignal after each yield, and stop if a newer keystroke has arrived. React's startTransition and useDeferredValue do exactly this: urgent updates (the input's value) render immediately, and the expensive transition re-renders in interruptible pieces and is dropped if superseded. See FE-02 for the React side.


Part 5: See it on your own device

The simulation below runs real work on your browser's main thread: a busy loop of the length you choose. Watch the animated bar and type into the box while it runs. The frame chart and the input-delay readout are real measurements from your device.

Loading simulation…

Try all three tabs. The total work is the same in each. What changes is when the browser gets a turn. In the second tab, compare the three ways of yielding: they differ in how many frames get painted and how much extra time the work takes.


Part 6: Patterns that keep interactions fast

  1. Show the urgent part first. Update the input, toggle the checkbox, show a spinner: let that frame paint, then do the heavy work. Yielding once right after the visible update is often enough to fix INP.
  2. Debounce input-driven work only when it's naturally batched, like search requests over the network. For local filtering, slicing plus cancellation feels faster than a 300 ms debounce.
  3. Batch DOM writes into one requestAnimationFrame. Many updates per frame (from a WebSocket, say) should be coalesced into one render.
  4. Separate reads from writes. Reading offsetHeight after changing styles forces a synchronous layout. Do all reads first, then all writes (FE-06).
  5. Keep startup work small. Hydration, analytics, and A/B testing setup often run as huge tasks right as the user first tries to tap. Split them, defer them, or hydrate less (islands, server components).
  6. Move pure computation off the thread entirely when it's big: parsing, sorting, aggregation, diffing. That's Chapter 3.

Numbers to remember

NumberMeaning
16.7 ms / 8.3 msFrame interval at 60 Hz / 120 Hz
~10 msPractical JS budget per frame during animation or scrolling
50 msLong task threshold
100 msResponse time that feels instant
200 ms"Good" INP at the 75th percentile. Over 500 ms is "poor".
4 mssetTimeout clamp after 5 nested calls
4–6×Typical CPU gap between a developer laptop and a mid-range phone. Always profile with CPU throttling.

The DevTools workflow

  1. Performance panel, CPU throttling 4×, record while you perform the slow interaction.
  2. Look at the Main track: long tasks have a red corner. The Interactions track shows each interaction's INP split into input delay, processing and presentation delay.
  3. Click the long task and open Bottom-Up sorted by self time to find the functions doing the work.
  4. In the field, the Long Animation Frames API (PerformanceObserver with type long-animation-frame, Chromium) reports slow frames with the scripts that caused them. Send these to your RUM (FE-08) to find slow interactions you can't reproduce locally.
  5. The web-vitals library's attribution build reports which element, which interaction type, and which phase dominated each slow INP.

Cheat sheet

SituationDo
A task over 50 msSplit it: time-slice with scheduler.yield() + fallback
Frames must keep painting during the workYield with an ordinary task (MessageChannel), or use a worker
Typing lags behind heavy workUpdate the input first, then yield, then compute. Cancel stale work.
React re-render is the heavy partstartTransition / useDeferredValue, and render fewer rows
Many updates per secondCoalesce into one requestAnimationFrame
Optional background workrequestIdleCallback (with fallback) or scheduler.postTask at background
Pure CPU work (parse, sort, aggregate)A Web Worker
"It's async so it's fine"Microtasks don't yield. Only tasks do.
Measuring in productionINP with attribution + Long Animation Frames in RUM

Say it in the interview

"INP is input delay plus processing plus presentation, so I'd profile with CPU throttling and see which dominates. For this filter, I'd update the input immediately, then run the filter in time-sliced chunks with scheduler.yield and a MessageChannel fallback, cancelling the work if a newer keystroke arrives. If the computation is still heavy I'd move it to a worker, and I'd virtualize the table so the render only touches visible rows."


Go deeper