Skip to content

Identifying Long Tasks and Layout Thrashing

advanced16 min read

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.

Mental Model

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:

  1. If the user interacts during a long task, the browser cannot process the event until the task finishes (input delay)
  2. If the event handler itself is a long task, processing takes too long (processing time)
  3. Both add up to the total interaction latency
Quiz
A user clicks a button while a 300ms JavaScript task is running. The click handler itself takes 50ms. What is the approximate INP for this interaction?

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 duration
  • blockingDuration — time over the 50ms threshold
  • scripts — array of script entries with source URLs, function names, and timing
Quiz
The Long Animation Frames API (LoAF) reports a frame with duration 200ms, blockingDuration 150ms, and two script entries: analytics.js (120ms) and your-app.js (60ms). What does blockingDuration represent?

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.

StrategyPriorityBest ForCaveat
scheduler.yield()Same priority — resumes quicklyUser-facing work that must complete soonNot supported in all browsers yet (use with feature detection)
setTimeout(0)Back of task queueGeneral-purpose yieldingContinuation may be delayed if other tasks are queued
requestIdleCallbackLowest — only runs when idleBackground work the user is not waiting forMay never run if the main thread stays busy
Common Trap

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:

Execution Trace
Iteration 1: Write
Set box[0].style.width = ...
Layout marked dirty
Iteration 1: Read
Read box[1].offsetWidth
FORCED LAYOUT — browser must recalculate to give accurate value
Iteration 2: Write
Set box[1].style.width = ...
Layout dirty again
Iteration 2: Read
Read box[2].offsetWidth
FORCED LAYOUT again
Iteration N
Repeat for every box
N forced layouts instead of 1 batched layout

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.

Quiz
You have 500 elements. In a loop, you read each element's offsetHeight, then set its style.height. How many layout calculations does the browser perform?

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, offsetHeight
  • scrollTop, scrollLeft, scrollWidth, scrollHeight
  • clientTop, clientLeft, clientWidth, clientHeight
  • getComputedStyle() — for most properties
  • getBoundingClientRect()
  • 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.

Quiz
In the Performance panel, you see a task with alternating yellow (JS) and purple (Layout) blocks repeating 200 times. What is this pattern called, and what is the fix?
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."

Key Rules
  1. 1Any main thread task over 50ms is a long task — it blocks user input and degrades INP
  2. 2Use scheduler.yield() (with fallback to setTimeout(0)) to break long tasks into smaller chunks
  3. 3Never interleave DOM reads and writes — batch all reads first, then batch all writes
  4. 4Layout-triggering properties (offsetWidth, getBoundingClientRect, etc.) force synchronous layout if styles are dirty
  5. 5In the Performance panel, look for the JS-Layout-JS-Layout alternating pattern as the signature of layout thrashing
What developers doWhat 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