Tags: web-dev concept

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.parse on 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
  • await chains 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. requestIdleCallback for 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.