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)
- Open DevTools (F12), Performance tab.
- Click Record (Ctrl/Cmd+E), perform the interaction, stop.
- Look at the Frames lane (top of main area) : red bars = dropped frames. Yellow bars = frame took longer than budget but did not drop.
- Look at the Main lane :
- Yellow blocks = Scripting (your JS).
- Purple blocks = Layout (style + reflow).
- Green blocks = Paint.
- Aqua = Composite (cheapest).
- 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-visibilitydecision 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)
- NEVER animate
width,height,top,left,margin,padding,font-size, orbox-shadowfor production interactions. Rewrite totransform/opacity/filter. - NEVER write
transition: all. List explicit composite-safe properties. - NEVER read layout-affecting properties (
offsetHeight,getBoundingClientRect, etc.) AFTER a write in the same synchronous block. Batch reads first. - NEVER mutate the observed element's size synchronously inside a
ResizeObservercallback. Wrap mutations inrequestAnimationFrame. - NEVER use
setIntervalfor animation. UserequestAnimationFramerecursion.setIntervaldoes not sync to refresh rate, does not pause with the tab, and accumulates drift. - NEVER apply
will-changeto many elements in static CSS. Apply on interaction-start, remove on interaction-end. - NEVER register non-passive
touchstart,touchmove, orwheellisteners unlesspreventDefault()is actually called.
Reference Links
references/methods.md: DevTools panel field guide, INP decomposition, rAF / ResizeObserver / passive-listener signaturesreferences/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, fullwill-changeandcontaindecision 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