Home / Articles / Animation & Motion /
Debugging transitions in DevTools
A systematic workflow for diagnosing stuttering transitions: the Performance panel, the Layers inspector, paint flashing, and the FPS meter.
the problem: your transition chugs only in production
You've written a beautiful hover animation. In Chrome DevTools, with the CPU throttled to a mid-range mobile device, it's buttery smooth. You ship it, and users on actual Android phones complain that it looks broken. There's a stutter on the first frame, or the animation drops to 30 fps halfway through.
Debugging this kind of performance problem requires a systematic approach. You need to identify whether the issue is in layout, paint, or compositing. You need to know which properties are triggering expensive operations. And you need tools that work on real mobile hardware, not just your high-end desktop machine.
the tools: devtools panels you'll need
Chrome DevTools has several panels for diagnosing animation performance. The five most important for transitions are:
- Performance panel: Records frame-by-frame activity and shows you exactly what work is happening on each tick.
- Layers panel: Shows all compositing layers, their memory usage, and why each layer was created.
- Rendering tab: Paint flashing, layer borders, and FPS meter — real-time diagnostics.
- Elements panel → Styles: Inspect which properties are being animated and whether
will-changeis applied. - Performance → Memory: Track GPU memory usage over time, especially useful for detecting layer leaks.
This guide walks through a typical debugging workflow using these tools.
step 1: isolate the transition
Before profiling, identify exactly what you're measuring. Open the page and reproduce the transition. Note which properties are changing — hover, focus, JavaScript class toggles. If there are multiple animations happening, you may need to isolate to a single element or interaction.
/* Inspect computed styles to see what's animating */
.element {
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.3s ease;
}
.element:hover {
transform: translateY(-4px);
opacity: 0.9;
}
In the Elements panel, select the animated element and in the Styles tab look for the transitions link. Clicking it shows all animated properties and durations. This is also where you can see if will-change is applied.
step 2: record with the performance panel
Open Performance (or Performance → Memory) and click the record button. Trigger the transition, wait a moment, then stop recording. If the transition is extremely fast, try increasing the duration to 1 second so you can see what's happening frame-by-frame.
The resulting flame chart shows two kinds of tracks: Main (where layout, paint, and JavaScript run) and Rendering (where compositing happens). Scroll to find the green "Frame" bars and red "Lost frames" markers. Every dropped frame means the browser missed the 16.6 ms budget for that tick.
step 3: analyze the flame chart
Each frame shows activity as colored bars: yellow for scripting, purple for styling, green for layout, orange for paint, cyan for composite. A smooth transition should show mostly cyan (compositing) with minimal paint or layout.
If you see tall green or orange bars during the transition, something is triggering layout or paint on every frame. Click on a bar to see the call stack and identify the responsible CSS property. Common culprits include:
width,height,top,left— any layout-affecting propertybox-shadow,text-shadow,border-color— paint-only but expensivefilter— paint in some cases, especially with complex blur values- JavaScript reading layout properties (
offsetWidth,getBoundingClientRect()) during the animation
step 4: check for forced synchronous layout
A particularly pernicious performance killer is "forced synchronous layout": reading a layout property inside an animation loop. For example:
/* BAD: each iteration forces a layout reflow */
element.addEventListener('transitionend', () => {
const width = element.offsetWidth;
console.log(width);
});
/* Or worse, in a rAF loop */
function animate() {
const rect = element.getBoundingClientRect(); // forces layout
element.style.transform = `translateY(${rect.height}px)`;
requestAnimationFrame(animate);
}
The Performance panel shows these as purple "Recalculate style" or "Update layout" bars squeezed between frames. The fix: avoid reading layout properties during animations, and use transform instead of layout properties whenever possible.
step 5: inspect the layers panel
Open the Layers panel (More tools → Layers) to see a 3D visualization of compositing layers. Each layer shows its z-index, memory cost, and the reason it was promoted.
Possible layer creation reasons:
- Will-change: transform
- Has a 3D transform
- opacity: 0.99
- position: fixed or sticky
- z-index != auto in a stacking context
- filter applied
If you see hundreds of layers (layer explosion), the browser is spending too much time managing textures. If you see few or no layers for animated elements, you may be missing an opportunity for compositor-only animations.
step 6: use paint flashing
In the Rendering tab, enable "Paint flashing". As you trigger the transition, the page flashes yellow wherever paint occurs. If the entire element or even the entire page is flashing, you're triggering unnecessary paint operations.
For a smooth transition, only the area that actually changes should paint. A card that lifts on hover should paint just the card's bounding box, not the entire viewport. If you see paint flashing beyond the animated element, something is causing a cascade of repaints.
real example: fixing a janky card transition
Here's a typical problem and how to solve it:
/* Bad: animates layout properties */
.card {
transition: top 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
top: -4px;
box-shadow: 0 8px 32px oklch(0% 0 0 / 0.3);
}
Analysis in DevTools: green "Layout" bars appear on every frame. Paint flashing covers the entire document. Fix:
/* Good: compositor-only */
.card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
transform: translateY(0);
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 32px oklch(0% 0 0 / 0.3);
}
/* Or better: avoid animating box-shadow entirely */
.card {
transition: transform 0.3s ease, opacity 0.3s ease;
transform: translateY(0);
opacity: 1;
}
.card:hover {
transform: translateY(-4px);
opacity: 0.9;
}
the fps meter: real-time feedback
In the Rendering tab, enable "Frame rate meter". A counter appears in the corner showing the current FPS. Use this for quick, qualitative assessment while experimenting with different CSS changes.
60 fps is ideal, 30 fps is acceptable but noticeable, below 24 fps feels broken. If your transition drops below 30 fps on a modern device, something is seriously wrong.
checklist: what to verify
Before declaring a transition production-ready, verify:
- Only
transformandopacityare being animated (orfilterfor simple blur) - No forced synchronous layout in JavaScript during the animation
will-changeapplied only where necessary and reset after animation- Layers panel shows appropriate number of layers (not hundreds)
- Paint flashing is limited to the animated element's area
- Performance panel shows no dropped frames on throttled CPU
- Real device performance matches desktop expectations
/* Quick audit in DevTools Console */
const el = document.querySelector('.animated');
const computed = getComputedStyle(el);
const animatedProps = ['transform', 'opacity', 'will-change'];
animatedProps.forEach(prop => {
console.log(`${prop}:`, computed[prop]);
});
Debugging cold-state transitions: first-load vs subsequent toggles
A transition might run smoothly on the second and third toggles, but stutter on the initial trigger after the page loads. This is the difference between cold state (nothing in cache) and warm state (all stylesheets, fonts, and layers already compiled). On cold load, the browser must parse CSS, compute styles, build the render tree, and promote any will-change or opacity layers to the compositor. These one-time costs happen before the user triggers their first interaction, but if your initial render blocks the main thread, the first transition can be delayed or drop frames.
To diagnose cold-state issues, use DevTools' "Disable cache (while DevTools is open)" option and do a hard reload (Ctrl+Shift+R each time). In the Performance panel, look for a spike in "Recalculate style" or "Update layout" at the start of the page load. If your transition fires immediately on mount, you're competing with the browser's layout work. Using @starting-style can help by deferring the first animation until after initial paint:
/* Defer first animation to avoid layout competition */
.card-enter {
opacity: 0;
transform: translateY(10px);
background: oklch(0.95 0.02 240);
}
.card-enter {
@starting-style {
opacity: 0;
transform: translateY(10px);
background: oklch(0.95 0.02 240);
}
}
.card-enter--active {
opacity: 1;
transform: translateY(0);
background: oklch(0.95 0.02 240);
}
The most reliable way to reproduce cold-state failures is to use the Animations panel's replay button. After recording a transition, click the replay icon to play it back from a clean state. This simulates exactly what happens on the first trigger: the panel reinitializes the element's computed styles, clears the compositor cache, and steps through the animation from frame zero. If a transition works on replay but fails on live interaction, you've got a warm-state artifact—likely a missing transform initialization or a race condition where will-change arrives too late.