SuperAPI AI Directory

frontend-errors-animation-jank

Frontend & websites

View repository

Updated 10-15 22:17

About this skill

Frontend Errors : Animation Jank

Diagnostic skill. The reader has a symptom (dropped frames, slow tap, scroll stutter, ResizeObserver warning) and needs to find the root cause and fix it. The companion skill [[frontend-perf-animation-gpu-containment]] teaches the prescriptive rules; this skill teaches how to find violations.

Quick Reference

Frame budget at 60 Hz / 120 Hz / 144 Hz

Per MDN : requestAnimationFrame (verified 2026-05-19), browsers call rAF callbacks at the display refresh rate :

Refresh rate Frame budget
60 Hz 16.67 ms
90 Hz 11.11 ms
120 Hz 8.33 ms
144 Hz 6.94 ms

For all main-thread work in a single frame (event handlers + style + layout + paint + composite + your JS), you have ONE frame budget. Going over even once means a dropped frame.

INP target

Per web.dev : Optimize INP (verified 2026-05-19) :

INP at p75 Verdict
<= 200 ms Good
200 to 500 ms Needs improvement
> 500 ms Poor

INP = input delay + processing duration + presentation delay.

Property-cost cheatsheet

Per web.dev : Animations Guide (verified 2026-05-19) :

Property animated Triggers Composite-safe?
transform composite only YES
opacity composite only YES
filter (most functions) composite only YES
top, left, right, bottom layout + paint + composite NO
width, height, inline-size, block-size layout + paint + composite NO
margin-*, padding-* layout + paint + composite NO
font-size layout + paint + composite NO
box-shadow paint + composite NO (paint storm)
background-position paint + composite NO (paint storm)
background-color paint + composite usually no (rarely composited)
color paint + composite NO (paint, but cheap per element)

ALWAYS rewrite to transform / opacity / filter for production interactions.

Decision Trees

Tree 1 : Jank symptom -> which DevTools panel?

Animation choppy or scroll stutters?
   -> Performance panel. Record 5 s of the interaction.
   -> Frames lane : red bars = dropped frames.
   -> Main lane : yellow = scripting, purple = layout, green = paint.
   -> Bottom-Up tab : sort by Total Time. Top entry is the culprit.

Whole page flashes when small element changes?
   -> Rendering tab (3-dots menu) -> Paint Flashing. Watch which area
      paints. If everything paints on every change : composite-layer
      audit needed.
   -> Layers panel. Look for one giant root layer (no isolation) or
      hundreds of tiny layers (will-change overuse).

Console warning "ResizeObserver loop completed with undelivered notifications"?
   -> Sources panel : pause on caught-or-uncaught errors? NO. The
      warning is non-throwing. Instead :
   -> Performance panel + ResizeObserver entries timeline. Find the
      callback that mutates the observed element's size.

INP regression in Real User Monitoring?
   -> Performance panel : record the regressing interaction.
   -> Insights tab : "Long INP interaction" insight. Click for the
      input delay + processing + presentation breakdown.

Tree 2 : Layout thrash vs paint storm vs main-thread block

Performance recording shows tall purple Layout bars in the main lane?
   -> LAYOUT THRASH. Look for read-then-write-then-read patterns in
      the Scripting (yellow) above. Common culprits :
      el.offsetHeight, getBoundingClientRect, offsetTop, scrollTop in
      a loop or after a write.
   FIX : batch reads first, then all writes. Or rAF-defer the writes.

Performance recording shows tall green Paint bars covering most of
the frame?
   -> PAINT STORM. Open Rendering tab > Paint Flashing : confirm a
      large region paints on each tick.
   FIX : composite-only properties (transform / opacity / filter), or
      isolate the animated element with `will-change: transform`
      / `contain: paint`.

Performance recording shows tall yellow Scripting bars before any
visual update?
   -> MAIN-THREAD BLOCK. Bottom-Up sorted by Total Time identifies
      the hot function. Common : large JSON.parse, sync XHR, heavy
      array filter / sort on a large list.
   FIX : yield to main thread between sub-tasks (await
      scheduler.yield() if available, else await new Promise(r =>
      setTimeout(r, 0))). Apply visible update first via rAF, defer
      background work after.

Performance recording shows touchstart / wheel handler bars before
the scroll begins?
   -> NON-PASSIVE LISTENER. The browser cannot start compositor
      scroll until the handler runs and decides whether to
      preventDefault.
   FIX : addEventListener(name, fn, { passive: true }) for handlers
      that do not preventDefault.

Tree 3 : ResizeObserver loop warning

Does the callback mutate the size of the observed element?
   YES -> wrap the mutation in requestAnimationFrame(...) to defer to
          next frame.
   NO  -> next question

Does the callback mutate an ancestor that resizes the observed element?
   YES -> same fix : rAF-defer, OR cache expected size in a WeakMap
          and skip the callback when delta is zero.
   NO  -> next question

Is the callback re-invoking observe() inside itself?
   YES -> remove the redundant observe() call.
   NO  -> the warning may be benign (deeper-element processing
          deferred to next paint per spec); confirm by checking it
          fires only once and the layout stabilizes.

Patterns

Pattern A : Read-then-write batching (forced sync layout fix)

// WRONG : forced sync layout on each iteration
items.forEach((el) => {
  const w = el.offsetWidth;          // read (forces layout)
  el.style.width = (w + 10) + 'px';  // write (invalidates layout)
});

// CORRECT : batch reads, then batch writes
const widths = items.map((el) => el.offsetWidth);  // all reads first
items.forEach((el, i) => {
  el.style.width = (widths[i] + 10) + 'px';        // all writes after
});

The pattern : reading any layout-affecting property (offsetWidth, offsetHeight, offsetTop, offsetLeft, getBoundingClientRect(), scrollTop, scrollHeight, clientWidth, clientHeight, getComputedStyle()) after a write forces the browser to flush pending styles and lay out NOW. Doing this in a loop costs O(n) layouts.

Pattern B : ResizeObserver loop fix (rAF defer)

// WRONG : callback writes to observed size in same frame
const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    // mutates the observed element's size synchronously :
    entry.target.style.width = (entry.contentRect.width + 8) + 'px';
  }
});

// CORRECT : defer mutations to next frame
const ro = new ResizeObserver((entries) => {
  requestAnimationFrame(() => {
    for (const entry of entries) {
      entry.target.style.width = (entry.contentRect.width + 8) + 'px';
    }
  });
});

Per MDN : ResizeObserver (verified 2026-05-19), the warning text is exact : "ResizeObserver loop completed with undelivered notifications." Spec deferral handles the case where deeper-element mutations are processed next paint. The rAF wrap moves the mutation OUT of the observer callback frame entirely.

Pattern C : rAF + setTimeout deferral for INP

button.addEventListener('click', (event) => {
  // 1. apply the visible update FIRST (small, in this frame)
  updateUiOptimistically(event);

  // 2. defer expensive work : let the browser paint, THEN do work
  requestAnimationFrame(() => {
    setTimeout(() => {
      doExpensiveBackgroundWork();
    }, 0);
  });
});

Per web.dev : Optimize INP (verified 2026-05-19) : "Combining requestAnimationFrame() with setTimeout() to prioritize render-critical updates before background tasks." This reduces the presentation-delay component of INP by ensuring the visual update is in the same frame as the user interaction.

Pattern D : Passive event listeners

// WRONG : non-passive handler blocks compositor scroll
window.addEventListener('touchstart', onTouchStart);
window.addEventListener('wheel', onWheel);

// CORRECT : opt out of preventDefault so compositor can start scrolling
window.addEventListener('touchstart', onTouchStart, { passive: true });
window.addEventListener('wheel',      onWheel,      { passive: true });

Only use { passive: false } (or omit the option, which is non-passive for touch/wheel in modern browsers) if the handler actually calls e.preventDefault().

Pattern E : will-change on, will-change off

// On interaction start :
draggable.style.willChange = 'transform';

// During interaction :
draggable.style.transform = `translate(${x}px, ${y}px)`;

// On interaction end :
draggable.style.willChange = 'auto';

will-change promotes the element to its own compositor layer, which costs GPU memory. Per web.dev : Animations Guide (verified 2026-05-19), apply it only when an interaction is about to begin and remove it when the interaction ends. NEVER set will-change: transform in static CSS on many elements.

Pattern F : Replace transition: all

/* WRONG : catches unintended layout-trigger properties */
.btn { transition: all 200ms ease; }

/* CORRECT : explicit composite-only properties */
.btn {
  transition:
    transform 150ms cubic-bezier(0.2, 0, 0, 1),
    opacity   150ms cubic-bezier(0.2, 0, 0, 1);
}

transition: all silently animates any property change. If any layout-trigger property changes (size, position, margin), the animation jank arrives without warning.

DevTools Workflow (Cheat Sheet)

Performance panel (record)

  1. Open DevTools (F12), Performance tab.
  2. Click Record (Ctrl/Cmd+E), perform the interaction, stop.
  3. Look at the Frames lane (top of main area) : red bars = dropped frames. Yellow bars = frame took longer than budget but did not drop.
  4. Look at the Main lane :
    • Yellow blocks = Scripting (your JS).
    • Purple blocks = Layout (style + reflow).
    • Green blocks = Paint.
    • Aqua = Composite (cheapest).
  5. Bottom-Up tab sorted by Total Time = the function consuming the most time.

Rendering tab (3-dots menu > More tools > Rendering)

  • Paint Flashing : green overlay on areas that paint each frame. If the whole viewport flashes : composite-layer fix needed.
  • Layout Shift Regions : blue overlay on regions that shift (for CLS, indirectly relevant).
  • Frame Rendering Stats : real-time FPS, GPU memory, dropped frames.

Layers panel (3-dots menu > More tools > Layers)

  • See every compositor layer with memory cost (KB).
  • One giant root layer + a few isolated layers : good.
  • Hundreds of small layers with will-change : memory pressure, fix needed.

Insights / "Forced Reflow" warning

Recent DevTools surfaces "Forced reflow is likely a performance bottleneck" inline in the recording. Click the warning to jump to the offending stack frame.

Out of Scope

  • Core Web Vitals theory and LCP / CLS measurement (covered in [[frontend-perf-core-web-vitals-inp]]).
  • Compositor-only animation rules and the full will-change / contain / content-visibility decision tree (covered in [[frontend-perf-animation-gpu-containment]]).
  • Scroll-snap behavior and view-transition syntax (covered in [[frontend-impl-view-transitions-scroll-animations]]).

Hard Rules (Binding)

  1. NEVER animate width, height, top, left, margin, padding, font-size, or box-shadow for production interactions. Rewrite to transform / opacity / filter.
  2. NEVER write transition: all. List explicit composite-safe properties.
  3. NEVER read layout-affecting properties (offsetHeight, getBoundingClientRect, etc.) AFTER a write in the same synchronous block. Batch reads first.
  4. NEVER mutate the observed element's size synchronously inside a ResizeObserver callback. Wrap mutations in requestAnimationFrame.
  5. NEVER use setInterval for animation. Use requestAnimationFrame recursion. setInterval does not sync to refresh rate, does not pause with the tab, and accumulates drift.
  6. NEVER apply will-change to many elements in static CSS. Apply on interaction-start, remove on interaction-end.
  7. NEVER register non-passive touchstart, touchmove, or wheel listeners unless preventDefault() is actually called.

Reference Links

  • references/methods.md : DevTools panel field guide, INP decomposition, rAF / ResizeObserver / passive-listener signatures
  • references/examples.md : before / after recordings, layout-thrash fix, rAF-defer pattern, panel-animation rewrite (width -> transform: scaleX)
  • references/anti-patterns.md : 6 anti-patterns with symptom, diagnostic step, root cause, fix
  • MDN : requestAnimationFrame (verified 2026-05-19)
  • MDN : ResizeObserver (verified 2026-05-19)
  • MDN : contain (verified 2026-05-19)
  • web.dev : Animations Guide (verified 2026-05-19)
  • web.dev : Optimize INP (verified 2026-05-19)

Cross-References

  • [[frontend-perf-animation-gpu-containment]] : compositor-only animation rules, full will-change and contain decision trees
  • [[frontend-perf-core-web-vitals-inp]] : INP measurement, the three Core Web Vitals
  • [[frontend-visual-micro-interactions]] : easing and timing for hover / press / focus
  • [[frontend-impl-view-transitions-scroll-animations]] : scroll-driven animations and view transitions