Interaction
Long Animation Frames

Long Animation Frames (LoAF)

Overview

Tracks Long Animation Frames (opens in a new tab) 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:

MetricDescription
DurationTotal frame time from start to render complete
Blocking DurationTime exceeding 50ms threshold (impacts INP)
Work DurationJavaScript execution time
Render DurationStyle calculation, layout, and paint
Style & Layout DurationTime in style/layout specifically

LoAF vs Long Tasks:

AspectLong TasksLong Animation Frames
Threshold> 50ms> 50ms
Script attributionLimitedFull (invoker, source URL, function)
Render timeNot includedIncluded
Forced layoutsNot detectedDetected and measured

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

LoAF Timeline Breakdown:

Comparison: Long Tasks vs LoAF:

Snippet

// Long Animation Frames Analysis
// https://webperf-snippets.nucliweb.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,
    };
  };
 
  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 });
 
  // 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;
    }
 
    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 {
      total: allLoAFs.length,
      withBlocking: blocking.length,
      totalBlockingTime: totalBlocking,
      worstBlocking,
      topScripts: Array.from(scriptStats.values())
        .sort((a, b) => b.totalDuration - a.totalDuration)
        .slice(0, 5),
    };
  };
 
  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;",
    ""
  );
})();

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:

SectionDescription
StatisticsTotal LoAFs, blocking time, worst frame
Top ScriptsScripts consuming the most time
Forced Style/LayoutTotal time spent in layout thrashing
RecommendationsSpecific optimizations

Key Metrics Explained

MetricWhat it meansTarget
Blocking DurationTime > 50ms that blocks interactions0ms ideal
Work DurationTime executing JavaScriptMinimize
Forced Style & LayoutLayout thrashing (read after write)0ms
Script DurationIndividual script execution time< 50ms each

Common Patterns

PatternDetectionSolution
Long script executionHigh work durationBreak into smaller tasks, use workers
Layout thrashingForced S&L > 0Batch DOM reads before writes
Many small scriptsHigh script countConsolidate event handlers
Render bottleneckHigh render durationReduce DOM size, use content-visibility

Further Reading