Break up long tasks to keep a page responsive
A long task freezes the page for 50 milliseconds or more. How to find them, and how to split them with yielding, time-boxed loops, priorities and workers.
In this guide
- What a task is, and why 50 milliseconds
- Step 1: find the long tasks
- Step 2: choose a technique
- Technique 1: yield between steps
- Technique 2: process a big list in time-boxed chunks
- Technique 3: give work a priority
- Technique 4: do less in the first place
- Technique 5: move the work off the main thread
- Mistakes to avoid
- Check that it worked
- Check your own page
- Sources
What a task is, and why 50 milliseconds
A task is a discrete piece of work the browser does: running a script, parsing HTML, calculating styles, drawing. The main thread is where most tasks run, and almost all the JavaScript you write works there. It can process only one task at a time.
web.dev defines a long task as one that stretches beyond 50 milliseconds. If the visitor taps or types while a long task runs, or if an important update to the screen is due, the browser is delayed in handling it. That delay is the input delay part of Interaction to Next Paint. In a lab test, the portion of each long task above 50 milliseconds is added up as Total Blocking Time. Breaking up a long task therefore helps the metric you see in the field and the one in the lab.
JavaScript uses a run-to-completion model: a task runs until it finishes, however long that takes. So a function like this one is a single long task, however neatly it is organized:
function saveSettings() {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}Splitting code into functions makes it readable. It does not make the browser's work any shorter. To let the browser breathe, you have to hand control back to it between the pieces. That is called yielding to the main thread.
Step 1: find the long tasks
In Lighthouse. The diagnostic called Avoid long main-thread tasks lists the longest tasks, up to 20, that ran for 50 milliseconds or more at the top level, with the script that caused each one. Lighthouse describes the list as useful for finding the worst contributors to input delay.
In Chrome DevTools. Record a load, or record yourself doing the slow thing, in the Performance panel. Long tasks are marked with a red triangle in the corner, and the part beyond 50 milliseconds is shaded with red stripes. Click a task and look at the Bottom-Up tab, grouped by activity, to see what took the time inside it. Often it is one loop, one library call or one forced layout.
In the field. The Long Animation Frames interface reports the frames that blocked the main thread, with details about the scripts that ran in them. At the time of writing it works in Chrome and Edge, and it is still marked experimental. The INP guide explains how to find the slow interactions of real visitors.
Step 2: choose a technique
| Situation | What to use |
|---|---|
| A handler does a visible update and a lot of other work | Do the visible part first, yield, then do the rest |
| A loop over thousands of items | Process in time-boxed chunks, yielding between them |
| Work nobody is waiting for, such as analytics | Run it at a low priority |
| Heavy computing that does not touch the page | Move it to a web worker |
| Many widgets starting up at load | Start each one when it is needed |
| A third-party tag that fires early | Delay it. See third-party scripts |
Technique 1: yield between steps
Yielding means ending your turn so that other work can run, then continuing. The modern way is scheduler.yield(), which pauses your function and resumes it as a prioritized task. MDN says the continuation defaults to the user-visible priority. It works in Chrome 129 and later and in Firefox 142 and later, and not in Safari. A small helper adds a fallback:
const yieldToMain = () =>
globalThis.scheduler?.yield
? scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
async function saveSettings() {
showSpinner(); // visible: do it now
updateUI();
await yieldToMain(); // let the browser paint and handle input
saveToDatabase(); // not visible: can wait a turn
await yieldToMain();
sendAnalytics();
}A few points about this pattern:
- The timer is what yields. A promise that resolves at once does not give the browser a turn, because promise callbacks run as microtasks, inside the current task. web.dev stresses that it is the
setTimeout()call that creates the new task. - Where the rest of the work lands. After a
setTimeout, the continuation goes to the end of the task queue, behind everything already waiting.scheduler.yield()is meant to resume sooner, so your work keeps its place. - Do not yield after every line. If two functions both produce critical updates to the screen, run them together. Yield between the critical work and the work that is not visible.
Technique 2: process a big list in time-boxed chunks
A loop over a large array may run quickly for each item and still be long in total. Count time, not items, because items differ in cost and devices differ in speed:
async function processItems(items, handleItem) {
let deadline = performance.now() + 40; // stay well under 50 ms
for (const item of items) {
handleItem(item);
if (performance.now() > deadline) {
await yieldToMain();
deadline = performance.now() + 40;
}
}
}Each chunk is a short task, and the browser can respond to input between chunks. Show progress if the job takes long, so the visitor knows something is happening. The cost is that the whole job takes a little longer, because other work now takes turns with it.
Technique 3: give work a priority
The Scheduler API lets you say how urgent a job is:
if (globalThis.scheduler?.postTask) {
scheduler.postTask(sendAnalytics, { priority: 'background' });
} else {
setTimeout(sendAnalytics, 0);
}postTask() takes three priorities: user-blocking for critical work, user-visible as the default, and background for the lowest. It works in Chrome 94 and later and Firefox 142 and later, not in Safari, so keep a fallback.
Two older tools deserve a warning:
- `requestIdleCallback()` runs work when the browser is idle. web.dev points out that it schedules at the lowest priority, so when the main thread is busy the callback may never run. It is fine for truly optional work. Safari has not shipped it.
- `isInputPending()` tells you whether the visitor is trying to interact, so a loop can yield only when needed. It is a Chromium-only interface that is still marked experimental. A time-based deadline, as above, works everywhere.
Technique 4: do less in the first place
Breaking up work spreads it out, but removing it is better.
- Skip work the visitor cannot see. Create hidden panels, tabs and menus when they are opened.
- Do not repeat work. Parse a response once and keep the result. Do not recalculate on every key press when the answer only matters when typing stops.
- Do not mix reading and writing layout values in a loop. Reading a measurement after changing a style forces the browser to calculate the layout at once, and in a loop that can happen hundreds of times. Read everything first, then write.
- Render long lists in pieces. Show 50 rows and load more, or draw only the rows in view.
- Ship less code. Parsing and compiling code is also tasks. See unused code.
Technique 5: move the work off the main thread
A web worker runs JavaScript on another thread, so a heavy job cannot block taps. A worker has no access to the page itself, so it suits pure computation: parsing a large file, searching, sorting, image processing or encryption. It sends results back with messages. The cost is the work of passing data between the threads, so it pays off for jobs that take real time. The full pattern, with the limits and a small library that makes messages easier, is in web workers: moving heavy work off the main thread.
Mistakes to avoid
- Wrapping everything in `setTimeout(fn, 0)`. Delaying work only helps if the browser gets something useful to do in the gap.
- Treating `async` as threading. An
asyncfunction still runs on the main thread, and code before its first real wait is part of the same task. - Yielding in a tight loop on every iteration. Each yield has a cost, which is why the loop above uses a time budget.
- Using idle time for important work. It may be late or never.
- Fixing the lab and ignoring the field. Check the INP of real visitors after a change.
Check that it worked
- Record the same action in the Performance panel again. The long task should now be several short ones, with gaps between them.
- Run the lab test and check that Total Blocking Time went down.
- Wait for the field data. Interaction to Next Paint is judged over 28 days of real use. See the INP guide.
Check your own page
Run the free audit and look at the Avoid long main-thread tasks list and the Total Blocking Time. If most long tasks belong to scripts from other companies, start with third-party scripts. If they belong to your own code, start with the longest one on the list.
Sources
- Optimize long tasks on web.dev: what a task is, the 50 millisecond rule, yielding,
isInputPending()and the Scheduler API. - Scheduler: yield() on MDN: how the continuation is prioritized, and browser support.
- Long tasks in DevTools on web.dev: finding long tasks in the Performance panel.
- Lighthouse: Avoid long main-thread tasks and the Total Blocking Time documentation on Chrome for Developers.
Start here
Free website audit
Live Google data for any public page: mobile and desktop.
Mobile + desktop · Lighthouse lab test · Real-user Chrome data when Google has it
Rather have this done for you? Speed optimization · Book a call