# Long animation frames (LoAF)

> Source: <https://webperf-snippets.net/Interaction/Long-Animation-Frames>

Tracks [Long Animation Frames](https://developer.chrome.com/docs/web-platform/long-animation-frames) to identify JavaScript and rendering work that blocks the main thread. LoAF is the underlying API that powers INP debugging and provides detailed attribution for slow interactions.

### Why this matters

LoAF is the successor to the Long Tasks API and provides much more detailed information about what's blocking your page. It tells you exactly which scripts ran, how long they took, and whether they caused forced layouts. This is essential for debugging slow interactions and improving INP scores.

### What is a long animation frame?

A frame is considered "long" when it takes more than 50ms. The API provides detailed breakdown of where time is spent:

| Metric                      | Description                                    |
| --------------------------- | ---------------------------------------------- |
| **Duration**                | Total frame time from start to render complete |
| **Blocking Duration**       | Time exceeding 50ms threshold (impacts INP)    |
| **Work Duration**           | JavaScript execution time                      |
| **Render Duration**         | Style calculation, layout, and paint           |
| **Style & Layout Duration** | Time in style/layout specifically              |

### LoAF vs long tasks

| Aspect             | Long Tasks   | Long Animation Frames                |
| ------------------ | ------------ | ------------------------------------ |
| Threshold          | > 50ms       | > 50ms                               |
| Script attribution | Limited      | Full (invoker, source URL, function) |
| Render time        | Not included | Included                             |
| Forced layouts     | Not detected | Detected and measured                |

> **Note:** **Tip:** LoAF helps answer "Why was my interaction slow?" by showing exactly which scripts ran and how long each phase took.

### LoAF timeline breakdown

Diagram: Timeline of a long animation frame: two script executions, then the rendering phase with style calculation, layout and paint; the 50 ms threshold applies to the rendering phase

### Anatomy of a long frame

Pick a scenario to see where the time of a frame goes, which part counts as blocking, and what the entry reports for each one:

Interactive demo: [Interactive timeline of an animation frame in five scenarios, showing script, forced layout, third-party script and render time against the 50 ms limit, with the matching PerformanceLongAnimationFrameTiming fields](https://webperf-snippets.net/demos/long-animation-frames.html). Switch scenarios, replay the frame and select a segment to see which entry field reports it.

### Comparison: Long tasks vs LoAF

Diagram: Long Tasks API with limited attribution and no render time, replaced by the Long Animation Frames API with full script attribution, render time and forced layout detection

### Snippet

```js
// Long Animation Frames Analysis
// https://webperf-snippets.net

(() => {
  const formatMs = (ms) => `${Math.round(ms)}ms`;

  // Rating based on blocking duration
  const valueToRating = (blockingDuration) =>
    blockingDuration === 0 ? "good" : blockingDuration <= 100 ? "needs-improvement" : "poor";

  const RATING_COLORS = {
    good: "#0CCE6A",
    "needs-improvement": "#FFA400",
    poor: "#FF4E42",
  };

  const RATING_ICONS = {
    good: "🟢",
    "needs-improvement": "🟡",
    poor: "🔴",
  };

  // Track all LoAFs and events
  const allLoAFs = [];
  const allEvents = [];

  const getScriptSummary = (script) => {
    const invoker = script.invoker || script.name || "(anonymous)";
    const source = script.sourceURL
      ? script.sourceURL.split("/").pop()?.split("?")[0] || script.sourceURL
      : "";
    return { invoker, source, type: script.invokerType || "unknown" };
  };

  const processLoAF = (entry) => {
    const endTime = entry.startTime + entry.duration;

    // Calculate derived metrics
    const workDuration = entry.renderStart
      ? entry.renderStart - entry.startTime
      : entry.duration;

    const renderDuration = entry.renderStart
      ? endTime - entry.renderStart
      : 0;

    const styleAndLayoutDuration = entry.styleAndLayoutStart
      ? endTime - entry.styleAndLayoutStart
      : 0;

    const totalForcedStyleAndLayout = entry.scripts.reduce(
      (sum, script) => sum + (script.forcedStyleAndLayoutDuration || 0),
      0
    );

    // Process scripts
    const scripts = entry.scripts.map((script) => ({
      ...getScriptSummary(script),
      duration: Math.round(script.duration),
      execDuration: Math.round(script.executionStart
        ? script.startTime + script.duration - script.executionStart
        : script.duration),
      forcedStyleAndLayout: Math.round(script.forcedStyleAndLayoutDuration || 0),
      startTime: Math.round(script.startTime),
    }));

    return {
      startTime: Math.round(entry.startTime),
      duration: Math.round(entry.duration),
      blockingDuration: Math.round(entry.blockingDuration),
      workDuration: Math.round(workDuration),
      renderDuration: Math.round(renderDuration),
      styleAndLayoutDuration: Math.round(styleAndLayoutDuration),
      totalForcedStyleAndLayout: Math.round(totalForcedStyleAndLayout),
      scripts,
      entry,
    };
  };

  // Item returned for agents: the same shape at start and in the summary
  const toItem = (frame) => ({
    startTimeMs: frame.startTime,
    durationMs: frame.duration,
    blockingDurationMs: frame.blockingDuration,
    scripts: [...frame.scripts]
      .sort((a, b) => b.duration - a.duration)
      .slice(0, 10)
      .map((s) => ({
        invoker: s.invoker,
        source: s.source,
        durationMs: s.duration,
        forcedStyleAndLayoutMs: s.forcedStyleAndLayout,
      })),
  });
  const topFrames = (frames) =>
    [...frames]
      .sort((a, b) => b.blockingDuration - a.blockingDuration)
      .slice(0, 20)
      .map(toItem);

  const overlap = (e1, e2) =>
    e1.startTime < e2.startTime + e2.duration &&
    e2.startTime < e1.startTime + e1.duration;

  // LoAF Observer
  const loafObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      const processed = processLoAF(entry);
      allLoAFs.push(processed);

      // Only log frames with blocking duration
      if (entry.blockingDuration > 0) {
        const rating = valueToRating(entry.blockingDuration);
        const icon = RATING_ICONS[rating];
        const color = RATING_COLORS[rating];

        console.groupCollapsed(
          `%c${icon} Long Animation Frame: ${formatMs(entry.duration)} (blocking: ${formatMs(entry.blockingDuration)})`,
          `font-weight: bold; color: ${color};`
        );

        // Time breakdown
        console.log("%cTime Breakdown:", "font-weight: bold;");
        const breakdown = [
          { Phase: "Work (JS)", Duration: formatMs(processed.workDuration) },
          { Phase: "Render", Duration: formatMs(processed.renderDuration) },
          { Phase: "Style & Layout", Duration: formatMs(processed.styleAndLayoutDuration) },
        ];
        console.table(breakdown);

        // Visual bar
        const total = processed.duration;
        const barWidth = 40;
        const workBar = "█".repeat(Math.round((processed.workDuration / total) * barWidth));
        const renderBar = "░".repeat(Math.round((processed.renderDuration / total) * barWidth));
        console.log(`   ${workBar}${renderBar}`);
        console.log("   █ Work  ░ Render");

        // Forced style/layout warning
        if (processed.totalForcedStyleAndLayout > 0) {
          console.log("");
          console.log(
            `%c⚠️ Forced style/layout: ${formatMs(processed.totalForcedStyleAndLayout)}`,
            "color: #ef4444; font-weight: bold;"
          );
        }

        // Scripts
        if (processed.scripts.length > 0) {
          console.log("");
          console.log("%cScripts:", "font-weight: bold;");

          const scriptTable = processed.scripts.map((s) => ({
            Invoker: s.invoker.length > 40 ? s.invoker.slice(0, 37) + "..." : s.invoker,
            Type: s.type,
            Duration: formatMs(s.duration),
            "Forced S&L": s.forcedStyleAndLayout > 0 ? formatMs(s.forcedStyleAndLayout) : "-",
            Source: s.source.length > 25 ? "..." + s.source.slice(-22) : s.source,
          }));
          console.table(scriptTable);
        }

        // Find overlapping events (interactions during this frame)
        const overlappingEvents = allEvents.filter((e) => overlap(e, entry));
        if (overlappingEvents.length > 0) {
          console.log("");
          console.log("%c👆 Interactions during this frame:", "font-weight: bold;");
          overlappingEvents.forEach((e) => {
            console.log(`   ${e.name}: ${formatMs(e.duration)}`);
          });
        }

        console.groupEnd();
      }
    }
  });

  // Event Observer (for correlation)
  const eventObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.interactionId) {
        allEvents.push(entry);
      }
    }
  });

  loafObserver.observe({ type: "long-animation-frame", buffered: true });
  eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });

  // Summary function
  window.getLoAFSummary = () => {
    console.group("%c📊 Long Animation Frames Summary", "font-weight: bold; font-size: 14px;");

    if (allLoAFs.length === 0) {
      console.log("   No long animation frames recorded.");
      console.groupEnd();
      return {
        script: "Long-Animation-Frames",
        status: "ok",
        count: 0,
        details: { totalLoAFs: 0, withBlockingTime: 0, totalBlockingTimeMs: 0, worstBlockingMs: 0, topScripts: [] },
        items: [],
      };
    }

    const blocking = allLoAFs.filter((l) => l.blockingDuration > 0);
    const totalBlocking = blocking.reduce((sum, l) => sum + l.blockingDuration, 0);
    const worstBlocking = Math.max(...allLoAFs.map((l) => l.blockingDuration));
    const avgDuration = allLoAFs.reduce((sum, l) => sum + l.duration, 0) / allLoAFs.length;

    // Statistics
    console.log("");
    console.log("%cStatistics:", "font-weight: bold;");
    console.log(`   Total LoAFs: ${allLoAFs.length}`);
    console.log(`   With blocking time: ${blocking.length}`);
    console.log(`   Total blocking time: ${formatMs(totalBlocking)}`);
    console.log(`   Worst blocking: ${formatMs(worstBlocking)}`);
    console.log(`   Average duration: ${formatMs(avgDuration)}`);

    // Script analysis
    const scriptStats = new Map();
    allLoAFs.forEach((loaf) => {
      loaf.scripts.forEach((script) => {
        const key = `${script.invoker}|${script.source}`;
        if (!scriptStats.has(key)) {
          scriptStats.set(key, {
            invoker: script.invoker,
            source: script.source,
            count: 0,
            totalDuration: 0,
            totalForcedSL: 0,
          });
        }
        const stats = scriptStats.get(key);
        stats.count++;
        stats.totalDuration += script.duration;
        stats.totalForcedSL += script.forcedStyleAndLayout;
      });
    });

    if (scriptStats.size > 0) {
      console.log("");
      console.log("%c🎯 Top Scripts by Total Duration:", "font-weight: bold; color: #ef4444;");

      const topScripts = Array.from(scriptStats.values())
        .sort((a, b) => b.totalDuration - a.totalDuration)
        .slice(0, 10);

      const scriptTable = topScripts.map((s) => ({
        Invoker: s.invoker.length > 35 ? s.invoker.slice(0, 32) + "..." : s.invoker,
        Count: s.count,
        "Total Duration": formatMs(s.totalDuration),
        "Forced S&L": s.totalForcedSL > 0 ? formatMs(s.totalForcedSL) : "-",
        Source: s.source.length > 20 ? "..." + s.source.slice(-17) : s.source,
      }));
      console.table(scriptTable);
    }

    // Forced style/layout analysis
    const forcedSLTotal = allLoAFs.reduce((sum, l) => sum + l.totalForcedStyleAndLayout, 0);
    if (forcedSLTotal > 0) {
      console.log("");
      console.log(
        `%c⚠️ Total forced style/layout: ${formatMs(forcedSLTotal)}`,
        "color: #ef4444; font-weight: bold;"
      );
      console.log("   This indicates layout thrashing - reading layout after writing to DOM.");
    }

    // Recommendations
    if (worstBlocking > 50) {
      console.log("");
      console.log("%c💡 Recommendations:", "font-weight: bold; color: #3b82f6;");
      console.log("   • Break up long tasks using scheduler.yield() or setTimeout");
      console.log("   • Move heavy computation to Web Workers");
      console.log("   • Avoid forced synchronous layouts (read before write)");
      console.log("   • Defer non-critical work with requestIdleCallback");
    }

    console.groupEnd();

    return {
      script: "Long-Animation-Frames",
      status: "ok",
      count: allLoAFs.length,
      details: {
        totalLoAFs: allLoAFs.length,
        withBlockingTime: blocking.length,
        totalBlockingTimeMs: Math.round(totalBlocking),
        worstBlockingMs: Math.round(worstBlocking),
        topScripts: Array.from(scriptStats.values())
          .sort((a, b) => b.totalDuration - a.totalDuration)
          .slice(0, 5)
          .map((s) => ({ invoker: s.invoker, source: s.source, totalDurationMs: Math.round(s.totalDuration), count: s.count })),
      },
      items: topFrames(allLoAFs),
    };
  };

  console.log("%c🎬 Long Animation Frames Tracking Active", "font-weight: bold; font-size: 14px;");
  console.log("   Frames with blocking duration will be logged.");
  console.log(
    "   Call %cgetLoAFSummary()%c for full analysis.",
    "font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
    ""
  );

  // Synchronous return for agent (buffered entries)
  const loafBuffered = performance.getEntriesByType("long-animation-frame");
  const blockingLoafs = loafBuffered.filter((e) => e.blockingDuration > 0);
  const totalBlockingSync = blockingLoafs.reduce((sum, e) => sum + e.blockingDuration, 0);
  const worstBlockingSync = loafBuffered.length > 0 ? Math.max(...loafBuffered.map((e) => e.blockingDuration)) : 0;
  return {
    script: "Long-Animation-Frames",
    status: "tracking",
    count: loafBuffered.length,
    details: {
      totalLoAFs: loafBuffered.length,
      withBlockingTime: blockingLoafs.length,
      totalBlockingTimeMs: Math.round(totalBlockingSync),
      worstBlockingMs: Math.round(worstBlockingSync),
    },
    items: topFrames(loafBuffered.map(processLoAF)),
    message: "Tracking long animation frames. Call getLoAFSummary() for full script attribution.",
    getDataFn: "getLoAFSummary",
  };
})();
```

### Understanding the results

### Real-time output

Each blocking frame logs:

- Total duration and blocking duration with rating
- Time breakdown (Work vs Render)
- Visual bar showing time distribution
- Forced style/layout warnings
- Scripts with invoker, type, duration, and source
- Overlapping interactions (helps debug INP)

### Summary function

Call `getLoAFSummary()` in the console to see:

| Section             | Description                             |
| ------------------- | --------------------------------------- |
| Statistics          | Total LoAFs, blocking time, worst frame |
| Top Scripts         | Scripts consuming the most time         |
| Forced Style/Layout | Total time spent in layout thrashing    |
| Recommendations     | Specific optimizations                  |

### Key metrics explained

| Metric                    | What it means                        | Target      |
| ------------------------- | ------------------------------------ | ----------- |
| **Blocking Duration**     | Time > 50ms that blocks interactions | 0ms ideal   |
| **Work Duration**         | Time executing JavaScript            | Minimize    |
| **Forced Style & Layout** | Layout thrashing (read after write)  | 0ms         |
| **Script Duration**       | Individual script execution time     | < 50ms each |

### Common patterns

| Pattern               | Detection            | Solution                                |
| --------------------- | -------------------- | --------------------------------------- |
| Long script execution | High work duration   | Break into smaller tasks, use workers   |
| Layout thrashing      | Forced S\&L > 0      | Batch DOM reads before writes           |
| Many small scripts    | High script count    | Consolidate event handlers              |
| Render bottleneck     | High render duration | Reduce DOM size, use content-visibility |

### Browser support

| Feature                                                                                                               | Chrome |  Edge | Firefox | Safari |
| --------------------------------------------------------------------------------------------------------------------- | :----: | :---: | :-----: | :----: |
| [PerformanceLongAnimationFrameTiming](https://developer.mozilla.org/docs/Web/API/PerformanceLongAnimationFrameTiming) |  ✅ 123 | ✅ 123 |    ❌    |    ❌   |
| [PerformanceScriptTiming](https://developer.mozilla.org/docs/Web/API/PerformanceScriptTiming)                         |  ✅ 123 | ✅ 123 |    ❌    |    ❌   |
| [PerformanceObserver](https://developer.mozilla.org/docs/Web/API/PerformanceObserver)                                 |  ✅ 52  |  ✅ 79 |   ✅ 57  |  ✅ 11  |
| All of the above                                                                                                      |  ✅ 123 | ✅ 123 |    ❌    |    ❌   |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

### Further reading

- [Long Animation Frames API](https://developer.chrome.com/docs/web-platform/long-animation-frames) | Chrome Developers
- [Optimize long tasks](https://web.dev/articles/optimize-long-tasks) | web.dev
- [Avoid large, complex layouts](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing) | web.dev
- [LoAF explainer](https://github.com/w3c/long-animation-frames) | GitHub
