Keeping the main thread responsive
Break up long JavaScript tasks with scheduler.yield, requestIdleCallback, chunking, and Web Workers so input and painting stay responsive.
- 01Explain why pages freezeConnect long tasks to input delay, processing time, presentation delay, and INP.
- 02Break work into fair slicesUse a real chunking helper with injected clocks and yielding strategies.
- 03Choose 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.
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 FIRSTA 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.
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.
| Phase | What waits | What can improve it |
|---|---|---|
| Input delay | Time from user input until the event handler starts | Keep the main thread free; avoid long tasks before handlers. |
| Processing time | The event handler and related JavaScript work | Chunk, debounce, throttle, or move CPU-heavy data work. |
| Presentation delay | Time from finished handler until the next painted feedback | Avoid 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.
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 CHOICESYielding 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.
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.
const schedulerApi = globalThis.scheduler; if (schedulerApi?.postTask) { schedulerApi.postTask( () => console.log("background task"), { priority: "background" }, );} else { setTimeout(() => console.log("fallback task"), 0);}| Tool | What it does | Best for | Caution |
|---|---|---|---|
setTimeout(fn, 0) | Queues a later task | Universal fallback that yields to input and paint | Nested timers face the HTML 4 ms clamp after several levels. |
MessageChannel | Posts a message task without timer delay | Library-level task queues when you need broad support | Less ergonomic and no priority model. |
scheduler.postTask | Queues a prioritized task | Prioritizing user-blocking, visible, or background tasks | Chromium-first; always feature-detect. |
scheduler.yield() | Pauses the current async task and continues later with priority | Breaking long async work while preserving continuation priority | Chrome 129+, Firefox newer, no Safari stable support; use a fallback. |
requestIdleCallback | Runs when the browser is idle, with a deadline | Non-urgent cleanup, analytics, cache warmups | Not for urgent work and not supported in stable Safari. |
| Web Worker | Runs JavaScript on another thread | CPU-heavy data work with plain input and output | No 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 THROUGHChunking 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.
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 a deterministic run of the real processInChunks helper. The fake clock and fake yield make the order testable without waiting.
script
{ 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(),});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.
This bounded promise loop proves the concept: microtasks run before the timer, so chaining promises is not a safe way to yield to rendering.
script
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");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 URGENTrequestIdleCallback 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.
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);}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 THREADChunking 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.
// 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 });};| Question | Good sign | Warning sign |
|---|---|---|
| Is the work CPU-heavy? | Sorting 100,000 rows, parsing a large export, image processing | Formatting a few labels or six dates |
| Can the data cross the boundary cheaply? | Plain objects, arrays, or transferable ArrayBuffer values | Huge object graphs copied every keystroke |
| Does it need the DOM? | No DOM access; main thread renders the reply | Requires document, layout reads, focus, or direct React state |
| Can transferables help? | Typed-array bytes can move ownership | Both sides need the same buffer after sending |
| Can OffscreenCanvas help? | Canvas pixel work can move off thread when supported | DOM layout and ordinary element painting still stay main |
| Would RPC help? | Comlink-style wrappers make message replies feel like async functions | The 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 DIFFERENCEThe 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.
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 });Counter: 0
Input value: empty
- Try typing while each mode runs. Blocking pauses the counter; chunked and worker modes keep giving the page turns.
Pick a mode. The same bounded CPU budget is shown as one long task, chunked slices, or worker work.
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.
| Scenario | Likely choice | Reason |
|---|---|---|
| Search filtering a big local list | Chunk or worker | Chunk if rendering partial results matters; worker if CPU dominates. |
| Parsing a large JSON export | Worker | Parsing and transforming can block input, and the output is data. |
| Image processing | Worker plus transferables | Move bytes with ArrayBuffer; consider OffscreenCanvas for canvas pipelines. |
| Analytics batching | Idle callback | Non-urgent work can wait for spare time with a timeout fallback. |
| Drag feedback and animation | Main thread with requestAnimationFrame | Visual writes need frame alignment and DOM access. |
- 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
Sort each job by the first home you would consider. Then read the explanation for the trade-off.
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.
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 EXERCISESType the output order with spaces or commas.
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("microtask"));
console.log("sync");The output is sync, microtask, timer. The promise callback is a microtask, and the timer is a later task.
Type the three phases in order.
INP is input delay, processing time, and presentation delay. Those three phases cover waiting for the handler, running the handler, and painting the feedback.
How many chunks and yields does the replay use?
const items = ["A", "B", "C", "D", "E"];
// Budget groups them as [A,B], [C,D], [E].
console.log("chunks: 3");
console.log("yields: 2");There are three chunks and two yields: after chunk 1 and chunk 2. The final chunk has no remaining work.
Should urgent input feedback, such as a pressed button state, be scheduled with requestIdleCallback?
No. Urgent input feedback should not wait for idle time. Use normal event handling, short tasks, and frame-aware visual updates instead.
Can the worker directly update the DOM after sorting rows?
// main thread
worker.postMessage({ rows });
worker.onmessage = (event) => renderRows(event.data);
// worker
self.onmessage = (event) => {
self.postMessage(sortRows(event.data.rows));
};No. The worker cannot update DOM directly. It should send data back; the main thread renders rows, updates focus, and handles layout.
Quiz: check your understanding
7 QUESTIONSQuestion 1 of 7What is a long task in the browser performance model?
Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictsetTimeout(() => console.log("timer"), 0); Promise.resolve().then(() => console.log("microtask")); console.log("sync");Choose an answer to see the explanation.
Question 3 of 7Which statement about
scheduler.yield()is most accurate?Choose an answer to see the explanation.
Question 4 of 7Why does a promise loop still freeze the page?
Choose an answer to see the explanation.
Question 5 of 7Which work belongs in
requestIdleCallback?Choose an answer to see the explanation.
Question 6 of 7When does a worker not help?
Choose an answer to see the explanation.
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
requestIdleCallbackonly 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.