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 a page freezesShow how one long task delays clicks and painting, and name the three parts of INP.
- 02Split work into small chunksUse a real chunk helper with a time budget, and yield between chunks.
- 03Pick the right toolChoose between scheduler.yield, timers, idle callbacks, animation frames, and workers.
One thread does many jobs
A thread is one line of work that a computer runs, one step after another. In a browser tab, most jobs share one thread, called the main thread. It does all of these:
- It runs your page's JavaScript.
- It handles input: clicks, taps, and key presses.
- It works out the styles and the layout (where each box goes on the page).
- It paints: it draws a new picture of the page on the screen.
The main thread does one job at a time. Each job is called a task. While one JavaScript task runs, nothing else can happen. A click waits. Typing waits. The page does not paint, so the screen looks frozen.
Plain definition: keeping the main thread responsive means keeping every task short, so input and painting get a turn often. When a job is too big for one turn, you have three choices:
- Split it into small pieces, called chunks, with a break between them.
- Run it later, when the browser has nothing else to do.
- Move heavy number work to a Web Worker, which is a second thread that runs JavaScript.
Picture a shop with one cashier. One customer has a huge cart. The cashier scans all of it without stopping. The next customer cannot ask a question. The card reader waits. The receipt printer waits. A better cashier scans a few items, checks if anyone is waiting, and then goes on.
- 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 heavy data work to a worker
This lesson uses ideas from The event loop, Microtasks in depth, Timers, and Web Workers. It also points to Animation, Typed arrays, Measuring performance, and the next lesson, Debounce & throttle.
Long tasks and INP
MEASURE FIRSTA long task is a task on the main thread that runs for 50 ms or more. (ms means milliseconds; 1000 ms is one second.) A screen usually shows about 60 pictures each second, so one picture has about 16 ms. A 50 ms task is three pictures long. People can feel that, mostly on slower phones.
A simple guide to the time budgets, called the RAIL model, says:
- Answer input quickly, so a click feels instant.
- Keep each animation frame very short.
- Do extra work only when the browser is free.
- Do not let page loading block a click.
A 49 ms task can still make the page skip pictures. Several medium tasks in a row can feel slow too. Use 50 ms as the line for reporting long tasks. While the user is typing or clicking, aim for much shorter tasks.
INP means Interaction to Next Paint. It measures how long the page takes to answer a click, a tap, or a key press. The time starts at the input and ends when the next picture of the page is painted. INP has three parts:
The table shows each part: what the page waits for, and what can make it shorter.
| Part | What is the page waiting for? | What can make it shorter? |
|---|---|---|
| Input delay | The time from the click until your event handler starts. | Keep the main thread free, so no long task is running when the click comes. |
| Processing time | The time your event handler and its JavaScript take to run. | Split the work into chunks, run the handler less often, or move heavy data work to a worker. |
| Presentation delay | The time from the end of the handler until the next paint. | Avoid making the browser work out layout again and again, and do visual work just before a frame. |
In March 2024, INP replaced an older score, First Input Delay, as one of the Core Web Vitals. These are the main speed scores that Google tracks for real users. A good INP is 200 ms or less for at least 75 out of 100 real visits (the 75th percentile). That is why a test on one fast laptop is not enough: real users have slower devices.
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(entry.entryType, Math.round(entry.duration)); }}); const types = PerformanceObserver.supportedEntryTypes;if (types.includes("longtask")) { observer.observe({ type: "longtask", buffered: true });}if (types.includes("long-animation-frame")) { observer.observe({ type: "long-animation-frame", buffered: true });}A PerformanceObserver is an object that the browser calls when it records a new timing entry. Here is what the code does, line by line:
- Lines 1 to 5 make the observer. For each entry, it prints the entry type and its length in ms.
- Line 7 reads
supportedEntryTypes, the list of entry types this browser can record. - Lines 8 to 10 watch
longtaskentries, but only if the browser supports them. - Lines 11 to 13 watch
long-animation-frameentries: frames that took too long to paint. Only Chrome-like browsers have them, so the code checks first.
buffered: true also gives you entries recorded before the observer started. Use these entries together with DevTools and data from real users, as the Measuring performance lesson shows.
performance.mark("search-start");filterVisibleRows();performance.mark("search-end");performance.measure("search", "search-start", "search-end"); new PerformanceObserver((list) => { for (const entry of list.getEntriesByName("search")) { console.log(entry.name, Math.round(entry.duration)); }}).observe({ type: "measure" });- Line 1 sets a named point in time, a mark, called
search-start. - Line 2 runs the search.
- Line 3 sets a second mark,
search-end. - Line 4 makes a measure called
search: the time between the two marks. - Lines 6 to 10 watch for measures and print the name and the time of
search.
The time depends on the device, so the printed number changes from run to run. Compare runs with each other instead of expecting one exact number.
Open the Performance panel in DevTools and record while you click. Select the click in the recording. Then look at the main-thread row for long tasks, event handlers, layout, and paint. Last, compare what you found with the INP of real users. Your recording explains one run on your device; it does not show every user's device.
Ways to give the browser a turn
API CHOICESTo yield means to stop your work for a moment, let the browser do other jobs, and then go on. Where you stop matters:
- If you stop and go on in a new task, the browser can handle input and paint in between.
- If you go on in a microtask (a short follow-up job, such as a promise's
.then()callback), the browser usually cannot paint in between.
Each way to yield differs in which browsers have it, how urgent the next part is, and how soon your work goes on.
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);});- Line 2 checks if this browser has
scheduler.yield. Checking first like this is called feature detection. - Lines 3 and 4 wait for
scheduler.yield(), then return its name. - Lines 6 and 7 are the fallback, the backup plan: they wait for a
setTimeoutwith 0 ms, which runs in a new task, then return its name. - Lines 10 to 12 print which way was used.
scheduler.yield() pauses an async function and goes on again soon, and it keeps the work's place near the front of the line. Chrome has it from version 129, and Firefox from version 142. Safari does not have it. Because not every browser has it, real code checks first and keeps the setTimeout fallback.
const schedulerApi = globalThis.scheduler; if (schedulerApi?.postTask) { schedulerApi.postTask( () => console.log("background task"), { priority: "background" }, );} else { setTimeout(() => console.log("fallback task"), 0);}scheduler.postTask() queues a new task with a priority, a label for how urgent it is. Line 3 checks for it. Lines 4 to 7 queue a task with the priority "background", which means "not urgent". Line 9 is the fallback timer for browsers without it.
The table compares the ways to yield. Read each row as one tool.
| Tool | What does it do? | When is it a good fit? | What should you watch out for? |
|---|---|---|---|
setTimeout(fn, 0) | It queues fn to run later, in a new task. | It is the backup plan that works in every browser and lets input and paint happen first. | After a few timers inside timers, browsers wait at least about 4 ms each time. |
MessageChannel | It sends a message that runs in a new task, with no timer wait. | Libraries use it for their own task lines when every browser must work. | It is harder to use, and it has no priorities. |
scheduler.postTask | It queues a new task with a priority. | Use it to mark jobs as urgent, visible, or background. | Chrome had it first. Always check that it exists. |
scheduler.yield() | It pauses an async function and goes on soon, near the front of the line. | Use it to break up long async work and keep its place. | Chrome 129+ and Firefox 142+ have it, Safari does not. Keep a fallback. |
requestIdleCallback | It calls your function when the browser has free time, with a deadline. | Use it for work that can wait: cleanup, analytics, filling a cache. | It is not for urgent work, and Safari does not have it. |
| Web Worker | It runs JavaScript on another thread. | Use it for heavy data work with plain data in and out. | It cannot touch the DOM. Starting it and sending messages take time. |
setTimeout(fn, 0) works in every browser because it queues a new task. The Timers lesson explains one catch: after a few timers inside timers, browsers wait at least about 4 ms each time. Some libraries use MessageChannel instead, which avoids that wait. The Scheduler API (scheduler.yield and scheduler.postTask) adds priorities where a browser has it.
Split work into chunks
STEP THROUGHChunking is the usual way to handle a big array: do a small piece, yield, then go on. The function below, processInChunks, is a real helper from this lesson. It takes three options:
budgetMs: the time budget, how many ms one chunk may use before it yields.clock: the thing that tells the time. By default it isperformance.yieldFn: the function that yields. By default it isyieldToMainThread.
You can pass in your own clock and your own yield function. Tests and the step-through below use a pretend clock, so every run gives the same result.
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(); } }}- Lines 2 to 4 read the options, or use the defaults.
- Line 7 keeps going while items are left.
- Line 8 notes the time when this chunk starts.
- Lines 9 to 12 work on one item at a time. They stop when no items are left or the chunk has used up its budget.
- Lines 14 to 16 yield, but only if items are still left.
Step through one run of the real processInChunks helper. A pretend clock and a pretend yield make every run the same, with no 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 step-through uses five jobs, A to E. Each job has a pretend cost in ms. The budget is 5 ms.
- A (2 ms) and B (3 ms) fill the first chunk: exactly 5 ms.
- C (2 ms) and D (4 ms) make the second chunk: 6 ms. That is 1 ms over, because a chunk always finishes the job it started.
- E (1 ms) finishes the last chunk. No items are left, so there is no last yield.
This short promise loop shows the rule: all microtasks run before the timer, so a promise chain does not give the page a chance to paint.
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");This short promise loop shows a common mistake from the Microtasks lesson: a promise chain does not give the browser a chance to paint. The browser runs every waiting microtask before it paints. So long work must yield to a new task, or move off the main thread.
Work for free time, and work for the next frame
NOT URGENTrequestIdleCallback(fn) asks the browser to call fn when it is idle, which means it has nothing else to do. The browser passes your function a deadline object. deadline.timeRemaining() tells you how many ms of free time are left.
- Pass a
timeoutoption if the work must happen at some point. It sets the longest time the browser may wait. - Safari does not have
requestIdleCallback. So check for it first, and keep a fallback.
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);}- Line 2 makes
run, the function the browser calls when it is free. - Lines 3 to 5 send one item at a time, while free time is left and the queue is not empty.
- Line 6 asks for another free moment if items are still left.
- Lines 9 to 11 use
requestIdleCallbackif the browser has it, and wait at most 2000 ms. - Line 13 is the fallback: a timer that pretends there are 3 ms of free time.
Do not put these behind requestIdleCallback: a button's pressed look, form error messages, or a loading spinner. The user is waiting to see them. Free time is for work that can wait: cleanup, analytics, filling a cache early, and other background jobs.
requestAnimationFrame(fn) has a different job. It calls fn just before the next paint. Use it for visual work: reading where an animation is, moving an element with a transform, or drawing one frame on a canvas. The Animation lesson covers it in depth.
A timeout is not the same as free time. When the timeout runs out, the browser calls your idle function even if it is busy. So keep that function short too.
Move heavy work to a worker
ANOTHER THREADChunks still run on the main thread. Some jobs are heavy number work for the processor (the CPU), and they only turn input data into output data. For those jobs, a worker may be a better place. A worker is a second thread that runs JavaScript.
- A worker cannot read the layout or change the DOM (the page's elements).
- A worker can parse, search, sort, compress, and work on raw bytes, while the main thread keeps answering the user.
// 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 });};- Line 2 starts a worker from the file
workerUrl. - Line 3 sends the worker a message: the job type, the search word
"Ada", and the rows. - Lines 4 to 6 wait for the reply and put the matches on the page with
renderRows. - Lines 9 to 13 are the worker file. It reads the message, keeps the rows whose name includes the word, and sends back
{ matches }.
The table asks the questions to check before you move work to a worker. For each one, it shows a good sign and a warning sign. A transferable value, such as an ArrayBuffer of bytes, is handed over to the worker instead of being copied.
| Question | Good sign | Warning sign |
|---|---|---|
| Is the work heavy for the CPU? | Sorting 100,000 rows, parsing a big export file, or editing an image. | Formatting a few labels or six dates. |
| Is the data cheap to send? | Plain objects, arrays, or transferable ArrayBuffer values. | A huge tree of objects copied on every key press. |
| Does the work need the DOM? | No. The worker sends data, and the main thread draws it. | It needs document, layout sizes, focus, or React state. |
| Can transferables help? | Typed-array bytes can be handed over instead of copied. | Both threads still need the same buffer after sending. |
| Can OffscreenCanvas help? | Drawing pixels on a canvas can move to a worker, where the browser supports it. | Page layout and normal elements are still drawn by the main thread. |
| Would a helper library like Comlink help? | It lets you call worker functions like async functions. | It still sends messages and copies data underneath. |
const result = { matches: ["Asha", "Asha's team"] };console.log(result.matches.join(", "));// The worker sends data; the main thread renders it.The worker can send back the matching names. But only the main thread can change the page's DOM, so it does the last step. This example only shows the shape of the code, because the lesson editor cannot load a worker file or a full app page.
The Web Workers lesson covers how workers start and stop, how messages are copied, transferables, module workers, and errors. The Typed arrays lesson explains the byte buffers that often make sending data to a worker worth it.
Try it: feel a frozen page
FEEL THE DIFFERENCEEach run below does about half a second of busy CPU work. While it runs, type in the box and watch the spinner and the counter.
- Blocking runs all the work as one long task.
- Chunked does the same work in small chunks, with a yield between them.
- Worker runs the busy loop in a real worker and gets a reply when it is done.
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
What you typed: nothing yet
- Type in the box while each mode runs. Blocking stops the counter. Chunked and worker keep giving the page turns.
Pick a mode. Each one does the same CPU work: as one long task, in chunks, or in a worker.
Where you will use this
Real apps need this often. Here are some examples:
- A search page filters a long list.
- An admin tool reads a big export file.
- An image editor changes pixels.
- A dashboard sends analytics in batches.
- A code editor colors many lines of code.
Which tool fits depends on four things: how urgent the work is, whether it needs the DOM, how much data there is, and whether the work already waits for something, like a network reply.
const names = ["Asha", "Ben", "Asha's team"];const matches = names.filter((name) => name.includes("Asha"));console.log(matches.join(", "));Two names in the array contain Asha, so the output is Asha, Asha's team. For a much bigger list, keep the same filter but run it in chunks, or move the search work to a worker. Do not move the DOM updates into that worker.
The table matches common jobs to the tool you would try first.
| The job | Try first | Why |
|---|---|---|
| Filtering a big list on a search page | Chunks or a worker | Use chunks if you want to show results as they come in. Use a worker if the CPU work is the slow part. |
| Parsing a big JSON export | A worker | Parsing can block input, and the result is only data. |
| Editing an image | A worker with transferables | Hand over the bytes in an ArrayBuffer. For canvas drawing, look at OffscreenCanvas. |
| Sending analytics in batches | An idle callback | This work is not urgent, so it can wait for free time. Add a timeout and a fallback. |
| Feedback while dragging, and animation | The main thread with requestAnimationFrame | Visual changes need the DOM and must happen just before a frame. |
- Change a button's text after a click
- Filter 30,000 search results shown on the page
- Send saved analytics messages
- Change the bytes of a huge image
- Read
getBoundingClientRect()and change classes - Parse a 25 MB JSON export
- Fill a small cache after the page loads
- Color 2,000 lines of code
Put each job where you would try it first. Then read why.
Common mistakes
“A promise loop lets the page paint.”
Promise callbacks are microtasks. The browser runs all waiting microtasks before it paints, so a long chain still freezes the page.
“requestIdleCallback is a faster setTimeout.”
Idle callbacks are for work that can wait. They may not run soon, and Safari needs a fallback.
“Workers fix every slow page.”
Workers cannot touch the DOM. For tiny jobs, starting the worker and sending messages can take longer than the job itself.
“INP is only the time the handler takes.”
INP has three parts: input delay, processing time, and presentation delay, up to the next paint.
“isInputPending is the modern answer.”
navigator.scheduling.isInputPending() is deprecated, which means it is on its way out. Use chunks you have measured, and scheduler APIs with fallbacks.
button.addEventListener("click", () => { spinner.hidden = false; Promise.resolve().then(() => { expensiveLoop(); });});Line 2 shows the spinner. Lines 3 to 5 put the slow loop in a promise callback, which is a microtask. The browser runs that microtask before it paints. So the spinner may not show up until the slow work is already done.
Practice exercises
5 EXERCISESType the three words in the order they print, 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 runs later in a new task.
Type the three parts in order.
INP is input delay, processing time, and presentation delay. They are: waiting for the handler to start, running the handler, and waiting for the paint.
How many chunks and how many yields does the step-through 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: one after chunk 1 and one after chunk 2. No items are left after chunk 3, so it does not yield.
Should urgent feedback, such as a button's pressed look, wait for requestIdleCallback?
No. Feedback for the user must not wait for free time. Change it in the normal event handler, keep tasks short, and use requestAnimationFrame for visual changes.
After it sorts the rows, can the worker change the DOM itself?
// main thread
worker.postMessage({ rows });
worker.onmessage = (event) => renderRows(event.data);
// worker
self.onmessage = (event) => {
self.postMessage(sortRows(event.data.rows));
};No. A worker cannot change the DOM. It sends the sorted data back, and the main thread draws the rows, moves focus, and handles layout.
Quiz: check your understanding
7 QUESTIONSQuestion 1 of 7What is a long task?
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 sentence about
scheduler.yield()is right?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 job 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, handles input, works out style and layout, and paints. It does one task at a time.
- A long task is 50 ms or more. It delays input and makes INP worse. A good INP is 200 ms or less for at least 75 out of 100 visits.
- Microtasks do not let the page paint. A long promise loop can still freeze the page.
- Split big arrays into chunks with a time budget, and yield with
scheduler.yieldor a fallback timer. - Use
requestIdleCallbackonly for work that can wait, with a timeout and a fallback. - Move heavy data work to a worker when it is worth the cost of starting it and sending messages.
Final definition.
A responsive main thread keeps every task short. It yields between chunks of work, and it sends heavy data work to a worker when that is the better choice.
Up next: Debounce & throttle. There you will stop slow event handlers from starting too often in the first place.