Identifying Long Tasks and Layout Thrashing
The 50ms Budget That Rules Everything
The browser's main thread handles JavaScript execution, style calculation, layout, painting, and responding to user input — all on a single thread. When your JavaScript occupies that thread for more than 50ms straight, the user's next click, tap, or keystroke has to wait. That wait is what makes apps feel sluggish.
50ms is not arbitrary. Research on human perception shows that responses under 50ms feel "instant." Between 50-100ms, users notice the delay. Above 100ms, it feels broken. A long task is any single block of work on the main thread exceeding 50ms.
Imagine a single-lane road (the main thread). Normally, cars (tasks) zip through quickly — a 10ms function here, a 20ms paint there. User input is a VIP ambulance that needs to get through immediately. A long task is a massive 18-wheeler blocking the entire lane for 200ms. The ambulance (user input) sits there honking, waiting for the truck to pass. The user taps a button and nothing happens for 200ms. That is what long tasks do to responsiveness.
What Causes Long Tasks?
The Impact on INP
Interaction to Next Paint (INP) measures the time from when a user interacts (click, tap, key press) to when the browser paints the next frame showing the result. It is a Core Web Vital as of March 2024.
INP thresholds:
- Good: under 200ms
- Needs improvement: 200-500ms
- Poor: over 500ms
Long tasks directly worsen INP because:
- If the user interacts during a long task, the browser cannot process the event until the task finishes (input delay)
- If the event handler itself is a long task, processing takes too long (processing time)
- Both add up to the total interaction latency
Long Animation Frames API (LoAF)
The Long Animation Frames API (LoAF) is the successor to the Long Tasks API. Where the Long Tasks API only told you "a long task happened for X ms," LoAF gives you the full picture: which scripts contributed, which functions were called, and how much time each took.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('Long animation frame:', {
duration: entry.duration,
blockingDuration: entry.blockingDuration,
scripts: entry.scripts.map(s => ({
sourceURL: s.sourceURL,
sourceFunctionName: s.sourceFunctionName,
duration: s.duration,
invokerType: s.invokerType,
})),
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
LoAF entries fire for animation frames that take longer than 50ms. Each entry includes:
duration— total frame durationblockingDuration— time over the 50ms thresholdscripts— array of script entries with source URLs, function names, and timing
Yielding Strategies
The solution to long tasks is to break them into smaller chunks, yielding control back to the browser between chunks. This allows the browser to process pending user input, repaint, and handle other events.
Strategy 1: scheduler.yield()
The modern, purpose-built yielding API:
async function processLargeArray(items) {
const results = [];
for (let i = 0; i < items.length; i++) {
results.push(expensiveTransform(items[i]));
if (i % 100 === 0) {
await scheduler.yield();
}
}
return results;
}
scheduler.yield() pauses execution, lets the browser process pending events and paint, then resumes your task with the same priority. This is the key advantage — your task does not get deprioritized to the back of the queue.
Strategy 2: setTimeout(0)
The classic approach:
function processInChunks(items, chunkSize, callback) {
let index = 0;
function nextChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
callback(items[index]);
}
if (index < items.length) {
setTimeout(nextChunk, 0);
}
}
nextChunk();
}
setTimeout(0) yields to the browser, but your continuation goes to the back of the task queue. If other tasks are queued, they run first. This can make the total processing time longer.
Strategy 3: requestIdleCallback
For truly non-urgent work:
function processWhenIdle(items) {
let index = 0;
function processChunk(deadline) {
while (index < items.length && deadline.timeRemaining() > 1) {
expensiveTransform(items[index]);
index++;
}
if (index < items.length) {
requestIdleCallback(processChunk);
}
}
requestIdleCallback(processChunk);
}
This only runs when the browser is idle. Perfect for analytics, prefetching, or background computation that the user is not waiting for.
| Strategy | Priority | Best For | Caveat |
|---|---|---|---|
| scheduler.yield() | Same priority — resumes quickly | User-facing work that must complete soon | Not supported in all browsers yet (use with feature detection) |
| setTimeout(0) | Back of task queue | General-purpose yielding | Continuation may be delayed if other tasks are queued |
| requestIdleCallback | Lowest — only runs when idle | Background work the user is not waiting for | May never run if the main thread stays busy |
scheduler.yield() was added in Chrome 129. As of 2026, Safari and Firefox have adopted it, but always feature-detect: if (typeof scheduler !== 'undefined' && scheduler.yield). For older browsers, fall back to setTimeout(0). Never assume this API exists without checking.
Layout Thrashing
Layout thrashing (also called forced synchronous layout or forced reflow) happens when you interleave DOM writes and reads in a way that forces the browser to recalculate layout synchronously — potentially hundreds of times in a single frame.
How the Browser Normally Works
The browser batches style and layout changes. When you set element.style.width = '100px', the browser marks the layout as "dirty" but does not recalculate immediately. It waits until the end of the frame (or until something forces it) to process all changes at once.
When It Goes Wrong
const boxes = document.querySelectorAll('.box');
for (const box of boxes) {
box.style.width = box.offsetWidth + 10 + 'px';
}
This looks harmless but is devastating:
With 1,000 boxes, you get 1,000 forced layout recalculations instead of one. Each recalculation costs 5-50ms depending on DOM complexity. Total: seconds of main thread blocking.
The Fix: Batch Reads, Then Writes
const boxes = document.querySelectorAll('.box');
const widths = Array.from(boxes, box => box.offsetWidth);
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px';
});
All reads happen first (one layout calculation). Then all writes happen (layout dirty, but no read forces recalculation). The browser does one final layout at frame end.
The Fastdom Pattern
For complex applications, the fastdom library (or the pattern it implements) provides a structured way to batch reads and writes:
function updateLayout() {
fastdom.measure(() => {
const width = element.offsetWidth;
const height = element.offsetHeight;
fastdom.mutate(() => {
element.style.width = width * 2 + 'px';
element.style.height = height * 2 + 'px';
});
});
}
fastdom.measure() schedules reads. fastdom.mutate() schedules writes. Fastdom ensures all reads run before any writes within a frame, eliminating thrashing.
Properties That Trigger Forced Layout
Reading any of these properties after a style change forces a synchronous layout:
offsetTop,offsetLeft,offsetWidth,offsetHeightscrollTop,scrollLeft,scrollWidth,scrollHeightclientTop,clientLeft,clientWidth,clientHeightgetComputedStyle()— for most propertiesgetBoundingClientRect()innerText(requires layout to compute visible text)
How to Spot in the Performance Panel
Layout thrashing appears as many small purple "Layout" bars in rapid succession within a single task:
┌────────────── Task ──────────────────────────┐
│ ┌JS┐┌Layout┐┌JS┐┌Layout┐┌JS┐┌Layout┐┌JS┐...│
│ └──┘└──────┘└──┘└──────┘└──┘└──────┘└──┘ │
└──────────────────────────────────────────────┘
If you see this JS-Layout-JS-Layout pattern repeating rapidly, you have layout thrashing. Click on any of the Layout bars — Chrome will show a warning: "Forced reflow is a likely performance bottleneck" and highlight the JavaScript line that triggered it.
CSS containment as a layout thrashing mitigation
CSS contain: layout tells the browser that an element's internals do not affect the layout of elements outside it. This means forced layout triggered inside a contained element only needs to recalculate the subtree, not the entire document. For components that frequently update their own size or position, contain: layout (or contain: strict) can dramatically reduce the cost of each forced layout. It does not eliminate thrashing, but it reduces the per-reflow cost from "recalculate entire document" to "recalculate this subtree."
- 1Any main thread task over 50ms is a long task — it blocks user input and degrades INP
- 2Use scheduler.yield() (with fallback to setTimeout(0)) to break long tasks into smaller chunks
- 3Never interleave DOM reads and writes — batch all reads first, then batch all writes
- 4Layout-triggering properties (offsetWidth, getBoundingClientRect, etc.) force synchronous layout if styles are dirty
- 5In the Performance panel, look for the JS-Layout-JS-Layout alternating pattern as the signature of layout thrashing
| What developers do | What they should do |
|---|---|
| Setting element styles and reading offsetWidth in the same loop iteration Each read-after-write forces a synchronous layout recalculation — O(n) reflows instead of O(1) | Collect all measurements first in a separate pass, then apply all changes |
| Using requestAnimationFrame to fix long tasks rAF runs at the start of the next frame. If the rAF callback itself is a 200ms long task, you have not solved anything | rAF batches work per frame but does not yield mid-task — use scheduler.yield() or setTimeout(0) |
| Assuming long tasks only come from your code Third-party scripts run on the same main thread and compete for the same 50ms budget | Check third-party scripts (analytics, ads, widgets) — they are often the biggest offenders |
| Using requestIdleCallback for user-facing work requestIdleCallback may not run for seconds (or ever) if the main thread is busy | requestIdleCallback is for background tasks — use scheduler.yield() for work the user is waiting on |