Long Tasks and Blocking
Date: 2026-08-16
The main thread is one shared resource, and a task that runs longer than about 50 milliseconds stops everything — rendering, scrolling, and every click the user makes while it runs. It’s the direct cause of a page that looks loaded and doesn’t respond.
What it is
A long task is any single piece of work occupying the main thread for more than 50ms. The threshold comes from the point at which users begin perceiving a delay rather than instant response.
Because the event loop runs tasks to completion, a long task cannot be interrupted. During it:
rendering stopped
scroll stopped
animations frozen
clicks and taps queued, unhandled
input queued, unhandled
Nothing is broken. The thread is busy, and it’s the same thread that does all of the above.
Why 50ms
At 60Hz a frame is due every 16.7ms. A 50ms task misses three frames. Beyond that the page reads as stuck rather than slow.
Worked, on a 200ms task arriving as the user taps:
tap at t=0
task already running, 180ms remaining
├─ 180ms waiting for the thread
├─ 30ms your handler runs
└─ 16ms next frame renders
───────
226ms before the user sees anything happen
That 226ms is what Interaction to Next Paint measures. The handler was fast; the queue was not — which is why optimising the click handler wouldn’t have helped.
Where they come from
In rough order of frequency on a retail site:
- Third-party scripts. Tag managers, chat widgets, review platforms, personalisation and testing tools. Often the largest contributor, and the one nobody owns — Third-Party Scripts
- Framework hydration. Attaching interactivity to server-rendered markup, all at once, on load — Hydration
- Large JSON parsing.
JSON.parseon a big payload is synchronous and uninterruptible - Long loops over DOM elements, especially with layout reads interleaved — Reflow and Repaint
- Heavy style recalculation from a class change high in the tree
awaitchains that never yield — microtasks drain as one block, see Tasks and Microtasks
Breaking them up
The general technique is yielding: ending the current task so the browser can render and process input, then continuing.
// BLOCKS — one long task
for (const item of tenThousandItems) process(item);
// YIELDS — many short tasks, page stays responsive
async function run(items) {
for (let i = 0; i < items.length; i++) {
process(items[i]);
if (i % 100 === 0) await yieldToMain();
}
}
const yieldToMain = () => new Promise(r => setTimeout(r, 0));await Promise.resolve() does not yield. It queues a microtask, which drains within the same task, so the browser still can’t render. You need a genuine task boundary — setTimeout(…, 0), or scheduler.yield() where available [CHECK: current browser support for scheduler.yield() and scheduler.postTask()].
Other options:
- Move it off-thread. Pure computation with no DOM access belongs in a worker
- Do less. Virtualise long lists; parse smaller payloads; defer work until it’s needed
- Defer until idle.
requestIdleCallbackfor genuinely non-urgent work - Load third-party scripts late, and audit what they cost — Tag Manager Performance
Measuring
- Field: Interaction to Next Paint is the user-facing consequence, and the only measurement that reflects real devices
- Lab: DevTools Performance panel flags tasks over 50ms with a red corner. Throttle CPU to 4× or 6× to approximate a mid-range Android phone, because on a development machine almost nothing looks long
- Total Blocking Time sums the portion of each long task beyond 50ms — a useful lab proxy for INP
In plain terms: your laptop hides this problem. The devices your customers use are several times slower, and a task that takes 30ms in development can take 200ms in someone’s hand.