Fixing Long Tasks on the Main Thread
A long task is any stretch of work that keeps the browser's main thread busy for more than 50 milliseconds. During that time the page cannot paint, cannot react to a tap and cannot run anything else.
Visitors feel it as a page that looks ready but ignores them, a carousel that stutters or a search box that lags behind their typing. This guide is about the tasks themselves: how to find the ones real visitors hit, name the code responsible, work out what kind of work it is, and apply the fix that suits that kind of work, from deleting it to moving it off the main thread entirely.
Lighthouse reports this as Avoid long main-thread tasks.
What a long task is, and why 50 milliseconds
Browsers run almost everything a page does on one thread: parsing HTML, running JavaScript, working out styles and layout, and painting. Work is queued as tasks, and the browser can only check for new input and draw a new frame between tasks. The 50 millisecond threshold comes from the RAIL guidance: if every task is shorter than that, the browser can always respond to input within roughly 100 milliseconds, which people perceive as instant.
A task over 50 milliseconds is not a bug by itself. A single 60 millisecond task at page start-up is rarely noticed. Problems come from tasks of several hundred milliseconds, from many long tasks in a row, and from long tasks that land exactly when someone is trying to use the page.
Long tasks feed into several metrics. They are the raw material of Total Blocking Time in lab tests, they cause the input delay and processing time behind poor Interaction to Next Paint, and a long task that runs before the main image is added can hold back Largest Contentful Paint. This guide does not repeat how those metrics are calculated; the INP guide and the TBT guide cover that. Here the focus is on finding the tasks and changing the code inside them.
Finding the tasks real visitors hit
Lab traces on your own machine show the tasks that happen on your device, at your connection speed, on the pages you think to test. Real visitors on mid-range phones hit different ones. The Long Animation Frames API, available in Chromium-based browsers since 2024, reports slow frames from real visits together with the scripts that ran in them, which the older Long Tasks API never could.
// Report long animation frames with script attribution
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
if (frame.blockingDuration < 100) continue; // keep only the painful ones
const worst = [...frame.scripts].sort((a, b) => b.duration - a.duration)[0];
navigator.sendBeacon('/log/loaf', JSON.stringify({
page: location.pathname,
blocking: Math.round(frame.blockingDuration),
total: Math.round(frame.duration),
script: worst?.sourceURL,
fn: worst?.sourceFunctionName,
invoker: worst?.invoker, // e.g. an event listener or a timer
kind: worst?.invokerType // e.g. 'classic-script', 'event-listener'
}));
}
}).observe({ type: 'long-animation-frame', buffered: true });
}
Collect this for a few days and group the results by script and fn. The
same handful of functions usually account for most of the blocking time. The invokerType
tells you how the work started: classic-script or module-script means the cost
of evaluating a script file as it loads; event-listener means a handler; user-callback
means a timer or animation frame callback; resolve-promise means code running after a promise
settled, often after a fetch.
Firefox and Safari do not support this API yet, so treat the data as a sample from Chromium visitors. The code patterns it uncovers are the same in every browser.
The six kinds of long task and their fixes
Once you know which function is slow, look at what it is doing. Nearly every long task falls into one of these groups, and the right fix depends on the group.
| Kind of work | How it shows in a trace | Best fix |
|---|---|---|
| Evaluating a large script | A long Evaluate script or Compile script block when a file loads | Ship less code at start-up; load features when used. See the bundle size guide. |
| Initialising many features at once | One task calling dozens of init functions | Initialise only what is on screen; postpone the rest until idle or until needed. |
| Processing a big array | Loops, sort, filter or map
dominating the Bottom-Up view | Process less data, slice the loop by time, or move it to a Worker. |
| Parsing large data | JSON.parse or a Parse entry after a
request | Request smaller payloads; paginate; parse in a Worker. |
| Building lots of DOM | Heavy Recalculate style and Layout after script | Render fewer elements; render in batches; virtualise long lists. |
| Synchronous browser APIs | Time inside localStorage, synchronous requests
or large innerHTML assignments | Store less, switch to asynchronous APIs, avoid synchronous requests entirely. |
Work that belongs to a third-party tag falls outside this list, because you cannot edit it. The third-party tags guide covers when and how to load it instead.
Slicing work by time rather than by count
A common way to break up a loop is to pause every so many items. The trouble is choosing the number: 200 items might take 5 milliseconds on a laptop and 80 on a budget phone. Slicing by elapsed time adapts to the device automatically. The loop works until it has used its budget, pauses so the browser can paint and handle input, then carries on.
const pause = () =>
globalThis.scheduler?.yield
? scheduler.yield()
: new Promise((r) => setTimeout(r, 0));
async function runInSlices(items, handleItem, budgetMs = 30) {
let sliceEnds = performance.now() + budgetMs;
for (const item of items) {
handleItem(item);
if (performance.now() >= sliceEnds) {
await pause();
sliceEnds = performance.now() + budgetMs;
}
}
}
// Usage: build 3,000 map markers without freezing the page
await runInSlices(branches, addMarker);
Keep the budget well under 50 milliseconds so that each slice, plus any rendering that follows it,
stays short. Checking performance.now() on every item is cheap compared with almost any
real work inside the loop.
Two cautions. First, slicing makes the total job take slightly longer, because the browser does other things in between; that is the point, but it means the result arrives later. Show progress or partial results where people are waiting. Second, if the data can change while the loop is paused, for example because the visitor typed a new search, check whether the job is still wanted after each pause and stop early if not.
Moving computation into a Web Worker
A Web Worker runs JavaScript on a separate thread. It cannot touch the page, but for pure computation, such as filtering a large product list, searching an index, parsing a big file or crunching numbers for a chart, it removes the work from the main thread completely rather than just breaking it up.
// search-worker.js
let products = [];
self.addEventListener('message', (e) => {
if (e.data.type === 'load') {
products = e.data.products;
} else if (e.data.type === 'query') {
const q = e.data.text.toLowerCase();
const hits = products.filter((p) => p.name.toLowerCase().includes(q)).slice(0, 50);
self.postMessage({ id: e.data.id, hits });
}
});
// main.js
const worker = new Worker(new URL('./search-worker.js', import.meta.url), { type: 'module' });
let lastId = 0;
worker.postMessage({ type: 'load', products: catalogue });
searchInput.addEventListener('input', () => {
worker.postMessage({ type: 'query', id: ++lastId, text: searchInput.value });
});
worker.addEventListener('message', (e) => {
if (e.data.id === lastId) renderResults(e.data.hits); // ignore stale answers
});
Data sent to a worker is copied, and copying a very large object costs main-thread time too. Send
the big data once, as above, then send only small queries and small results. For large binary data such
as image pixels or audio, transfer an ArrayBuffer instead of copying it.
The fix, step by step
Step 1
Record long animation frames from real visits
Start with the JavaScript Checker's parse and execution times to see which scripts
cost the most. Then add a PerformanceObserver for long-animation-frame entries
to the live site, sending the page, blocking duration and the slowest script from each frame to your
analytics or a logging endpoint. Sample a share of visits on busy sites, and collect for several days
so weekday and weekend traffic are both represented.
Step 2
Name the script and function behind each frame
Group the collected frames by sourceURL and sourceFunctionName,
and rank them by total blocking time. Open the top few in a Chrome Performance recording with CPU throttling
at 4x or 6x, reproduce the page state visitors were in, and click the matching task. The Bottom-Up tab
shows exactly which functions inside that script used the time, and the call tree shows what triggered
them.
Step 3
Classify each task by the kind of work
For each slow function, decide which kind of work it is: evaluating a big script, initialising many features, processing a large array, parsing large data, building lots of DOM, or calling synchronous browser APIs. Write the kind next to it in your list. The kind decides the fix, and mixing up the kinds is the usual reason a change does not help.
Step 4
Cut work nobody is waiting for
Remove
code whose result nobody sees, such as features switched off in settings or initialisers for widgets
not on this template. Then postpone the rest: set up below-the-fold widgets when they scroll into view
with an IntersectionObserver, start rarely used features on first use, and run background
jobs such as prefetching or cache warming in requestIdleCallback with a timeout so they
still run on busy pages.
Step 5
Slice unavoidable loops by time, not count
For loops that must run on the main thread because they touch the page, check performance.now()
as you go and pause once about 30 milliseconds have passed, using scheduler.yield() where
supported and a zero timeout elsewhere. After each pause, check whether the job is still wanted, for
example because the visitor has typed a new search, and stop early if it is not.
Step 6
Move pure computation into a Web Worker
Move filtering, searching, sorting, parsing and number crunching that does not need the page into a Web Worker. Send large data to the worker once, then exchange small messages, and tag each request with an ID so stale answers are ignored. Transfer binary buffers rather than copying them. Keep only the final, small update of the page on the main thread.
Step 7
Hand the main thread less data
Ask
the server for only the fields and rows the page shows, paginate long lists and render only visible rows
with virtualisation. Build new DOM in batches, or from a template element, rather than thousands
of separate insertions. Replace large synchronous localStorage reads with IndexedDB, and
remove any synchronous requests left in older code, because each one freezes the page until the server
answers.
Step 8
Compare long frames before and after
Record the same interaction and page load with the same CPU throttling before and after each change, and compare the number and length of long tasks in the Main track. Re-run the JavaScript Checker to confirm execution time has dropped. After release, watch the long animation frame data from real visitors for a week to confirm the functions you fixed have left the top of the list.
Postponing start-up work safely
Much start-up work is not needed in the first second: analytics helpers, prefetching, restoring saved preferences for a widget further down the page. Deferring it spreads the cost into moments when the visitor is reading rather than tapping.
function whenIdle(job) {
if ('requestIdleCallback' in window) {
requestIdleCallback(job, { timeout: 3000 }); // run within 3 s even if never idle
} else {
setTimeout(job, 1500);
}
}
// Initialise the reviews carousel only when it approaches the viewport
const reviews = document.querySelector('[data-reviews]');
if (reviews) {
new IntersectionObserver(([entry], io) => {
if (entry.isIntersecting) { io.disconnect(); initReviews(reviews); }
}, { rootMargin: '400px' }).observe(reviews);
}
whenIdle(prefetchNextPage);
Idle callbacks run when the browser has nothing else to do, which on a busy page may be never, hence
the timeout. They are also given a deadline, so long jobs inside them should still be sliced. Safari
support for requestIdleCallback has lagged behind other browsers, which is why the fallback
is there.
Edge cases and traps
- Garbage collection. Traces sometimes show long pauses labelled as garbage collection. They come from creating and throwing away lots of objects; reuse arrays and objects in hot loops rather than creating new ones each time.
- One big innerHTML. Assigning a huge HTML string is a single task that cannot be split. Build the content in parts, or render only the first screenful and add the rest later.
- Regular expressions. A badly written pattern run over long text can take seconds. If a trace points at a regex, test it with long inputs and simplify it.
- Tasks you cannot see locally. Personalisation, A/B tests and consent tools behave differently for first-time visitors. Test in a fresh private window with the cookie banner still showing.
- Splitting without shrinking. Slicing a job into twenty pieces keeps the page responsive but uses the same total CPU and battery. Always try deleting or postponing the work first.
1. What counts as a long task?
Any single piece of work on the browser's main thread that lasts longer than 50 milliseconds. The figure comes from the aim of responding to input within about 100 milliseconds: if every task is shorter than 50, the browser always has time to react. One slightly long task at start-up rarely matters. Tasks of several hundred milliseconds, or long tasks that coincide with a visitor tapping, scrolling or typing, are the ones that make a page feel frozen.
2. What is the Long Animation Frames API?
It is a browser API, available in Chromium-based browsers since 2024, that reports frames which took longer than 50 milliseconds to produce and lists the scripts that ran in them, with the file, function and how each was started. The older Long Tasks API only said that a long task happened, with little detail about what caused it. Because it runs on real visits, it shows which code slows the page for actual visitors.
3. Should I use a Web Worker for everything?
No. A worker cannot touch the page, so anything that reads or changes elements has to stay on the main thread. Starting a worker and passing data back and forth also has a cost, and copying large objects can itself take noticeable time. Workers pay off for pure computation that takes tens of milliseconds or more on a mid-range phone, such as searching a large catalogue, parsing a big file or preparing chart data.
4. Does adding defer to a script fix long tasks?
No. The defer attribute changes when a script downloads and starts running, so it helps the page appear sooner, but the script takes exactly as long to run once it starts. A 400 millisecond task is still a 400 millisecond task, just later. To fix a long task, the work inside it has to be removed, postponed until it is needed, split into shorter pieces, or moved off the main thread into a Web Worker.
5. Why do long tasks show up on phones but not on my computer?
A developer laptop can run JavaScript several times faster than a typical mid-range phone, so a task that takes 30 milliseconds on your machine can take well over 100 on a visitor's handset. Use CPU throttling in the Chrome Performance panel to imitate a slower device when testing, and rely on field data such as long animation frame reports to see what real visitors experience, rather than judging by how the site feels on your desk.
6. Is time slicing the same as yielding to the main thread?
They are closely related. Yielding means pausing your code so the browser can handle input and paint before you continue. Time slicing is a way of deciding when to yield: instead of pausing every fixed number of items, you keep working until a time budget such as 30 milliseconds is used up, then yield. That adapts automatically to fast and slow devices, which a fixed item count cannot do, and it keeps each piece of work comfortably short.
7. Can third-party scripts cause long tasks?
Yes, and on many small-business sites they cause a large share of them. Tag managers, chat widgets, advertising and session recording tools often run substantial code at start-up. You cannot change their internals, but you can remove tags you no longer use, load the rest later or only on the pages that need them, and choose lighter alternatives. The long animation frame data shows the script address, so third-party work is easy to spot.
The JavaScript Checker loads your page in headless Chromium and measures parse and execution time for each script, alongside bundle size, third-party tags and console errors.
Open JavaScript Checker Fixes โ