This chapter follows The event loop, tasks, and microtasks, which explained that the browser renders and handles input only between tasks. This page is the troubleshooting side of that rule: what a blocked main thread looks like, how to find the task responsible, and how to fix it.
Symptom
One or more of these, usually after a specific interaction:
- A click, key press, or tap takes a visible moment to have any effect.
- A loading indicator set at the start of a handler never appears, or appears only together with the result.
- Animations and scrolling stutter or stop while something is being computed.
- Chrome’s console, with the Verbose level enabled, logs messages such as
[Violation] 'click' handler took 412ms. - Field data reports a poor Interaction to Next Paint (INP). A good INP is 200 ms or less, measured from the input to the next frame painted after it.
Cause
A single task runs for too long. While it runs, input events wait in their queue, and the browser cannot render a frame. The Long Tasks API counts anything over 50 ms as long, which keeps total response time under 100 ms even when an input arrives just as such a task starts.
Two patterns produce most long tasks. The first is plain synchronous work: parsing a large response, filtering or sorting thousands of items, rendering a big list, running a validation pass over a whole form.
The second looks asynchronous but is not. await yields only to microtasks, and microtasks run before the browser takes another task or renders:
async function processAll(items) { for (const item of items) { await processItem(item); // processItem does synchronous work and returns }}If processItem does not wait for something external, such as the network or a timer, each await resumes in a microtask straight away. The whole loop is one long task. In Node, a setTimeout(..., 0) queued before this loop ran only after every item had been processed. Making a function async changes how it returns its result, not where it runs.
Find the task
In Chrome DevTools:
- Open the Performance panel and start a recording.
- Perform the slow interaction, then stop the recording.
- In the Main track, tasks over 50 ms are marked with a red triangle and a striped section for the time above 50 ms.
- Select the task and open Bottom-Up to see which functions took the time, or Call Tree to see the path from the event handler down.
To find long tasks on real users’ devices, observe long animation frames. This API, available in Chromium-based browsers, reports frames delayed by more than 50 ms and names the scripts that caused them:
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { const scripts = entry.scripts.map( (script) => `${script.invoker} (${Math.round(script.duration)} ms)`, ); console.log(`Long frame: ${Math.round(entry.duration)} ms`, scripts); }});
observer.observe({ type: 'long-animation-frame', buffered: true });invoker names what started the script, such as BUTTON#save.onclick or a timer, and sourceURL on each script entry points to the file. Send those values to your analytics instead of logging them.
Fix 1: yield to the main thread
If the work has to happen on the main thread, split it into pieces and give control back to the event loop between them. The browser can then handle input and render between pieces.
export function yieldToMain() { if (globalThis.scheduler?.yield) { return scheduler.yield(); } return new Promise((resolve) => setTimeout(resolve, 0));}scheduler.yield() returns a promise that resolves in a new task. Its continuation is scheduled ahead of other tasks of the same priority, so your work resumes promptly once pending input has been handled. A setTimeout continuation goes to the back of the timer queue instead, behind every other timer the page has queued. scheduler.yield() is not in every browser yet, so feature-detect it as above.
Yielding after every item costs a task switch per item. Yield on a time budget instead:
import { yieldToMain } from './yield.js';
export async function processAll(items, processItem) { let deadline = performance.now() + 50; for (const item of items) { processItem(item); if (performance.now() >= deadline) { await yieldToMain(); deadline = performance.now() + 50; } }}Each piece runs for at most about 50 ms, however fast or slow each item is on a given device.
If the data can change while the work is paused between pieces — the user types again, or a newer response arrives — check for that after each yield and stop the outdated run. An AbortSignal is a convenient way to express that; see Cancellation with AbortController.
Fix 2: show the update first, defer the rest
An interaction feels slow when nothing visible happens. Often only part of the handler’s work is needed for the next frame. Do that part, let the browser paint it, and then do the rest:
import { yieldToMain } from './yield.js';
button.addEventListener('click', async () => { button.textContent = 'Saving…'; await yieldToMain(); const report = buildReport(form); await upload(report); button.textContent = 'Saved';});Yielding gives the browser a chance to paint before buildReport runs, but it does not guarantee a frame. When the update must be visible before the work starts, wait for the next frame and then for the task after it:
export function afterNextPaint() { return new Promise((resolve) => { requestAnimationFrame(() => setTimeout(resolve, 0)); });}The requestAnimationFrame callback runs just before the frame is painted. The setTimeout inside it queues a task, which runs after that frame.
Work that nobody is waiting for — analytics, prefetching, warming caches, writing drafts to storage — does not belong in the handler at all. Schedule it at low priority:
export function runWhenIdle(callback) { if (globalThis.scheduler?.postTask) { return scheduler.postTask(callback, { priority: 'background' }); } if (globalThis.requestIdleCallback) { return requestIdleCallback(callback, { timeout: 2000 }); } return setTimeout(callback, 0);}Neither scheduler.postTask nor requestIdleCallback is available in every browser, hence the fallbacks. The timeout makes sure an idle callback eventually runs on a page that is never idle.
Fix 3: move the work to a worker
Yielding keeps the page responsive, but the work still uses the main thread, and the total time goes up slightly. If the work is pure computation on data — parsing, compression, image processing, building a search index, diffing large documents — run it in a worker instead. The main thread only sends the input and receives the result.
Workers cannot touch the DOM, and data sent to them is copied unless it is transferred. That makes them a good fit for large computations on compact input, and a poor fit for work that is mostly DOM updates. Concurrency with workers covers setup, messaging, and transfer.
Choosing a fix
| Situation | Fix |
|---|---|
| Feedback waits for other work | Update first, then yield |
| Non-urgent work runs in the handler | Defer it at low priority |
| A long loop touches the DOM | Yield on a time budget |
| Heavy computation without the DOM | Move it to a worker |
| Same expensive work every time | Cache the result |
Measure again after the change, with the same recording or the same long-animation-frame data. The goal is no single task over 50 ms during the interaction, not a lower total time.