A website can load in a flash and still feel sluggish. The moment a menu opens, a list scrolls or a carousel glides, it is not the load time that shapes the impression but the render performance frame by frame. Jank arises when the browser fails to finish a new frame within its tight time window. At 60 frames per second, only 16.7 milliseconds remain per frame (web.dev). This article shows why just two CSS properties run cheaply on the compositor thread, how layout thrashing occurs and how to hunt down jank in the DevTools. Where the article on Interaction to Next Paint covers the reaction time to input, this one is about the visual smoothness of animations and scrolling -- that is, layout, paint and composite.
The 16.7 ms budget: why 60 fps leaves so little time
A typical display refreshes its image 60 times per second. The browser therefore has to deliver a finished frame every 16.7 milliseconds -- that follows from 1000 milliseconds divided by 60 (web.dev). Part of that window, however, goes to the browser's own housekeeping, so in practice only around 10 milliseconds per frame are left for your own work (Chrome for Developers). Exceed this budget and the frame cannot be finished in time: the image stalls, jumps or shudders -- the effect users perceive as jank. On modern 120-hertz displays the window even halves to roughly 8.3 milliseconds, tightening the requirement further (Chrome for Developers).
Unlike load time, what counts here is not a single point in time but consistency across many frames. A 60 fps animation feels fluid because every frame arrives on schedule. If just every third frame is dropped because a calculation runs too long, the perceived frame rate falls noticeably and the motion feels choppy. The browser then discards frames to catch up -- visible as a jump. This is exactly why render performance is its own discipline within frontend optimization, one that faster loading alone cannot solve.
Load time and render smoothness are two separate jobs
The rendering pipeline: from JavaScript to pixel
To turn HTML and CSS into visible pixels, the browser runs through a fixed sequence of steps -- the so-called rendering pipeline (web.dev). It begins with the execution of JavaScript, which often triggers the visual change. Then follow four phases: Style calculates which CSS rules apply to each element. Layout determines the size and position of every element -- a change here can affect the geometry of large parts of the page. Paint fills in the pixels with colors, text and images. Composite finally assembles the individual layers into the finished image (web.dev). The order is decisive: the earlier in this chain a change starts, the more follow-up work it triggers.
Style
The browser determines which CSS rules apply to each element -- color, size, visibility and more. Changes to selectors or inherited values kick off this phase.
Layout
Here the size and position of every element are computed. Because elements influence one another, a single change can re-trigger the geometry of large parts of the page -- the most expensive phase.
Paint
The browser fills in the pixels: text, colors, borders, shadows and images are drawn into layers. Large or complex areas cost noticeable time here.
Composite
The finished drawn layers are assembled into the visible image. This step runs particularly efficiently and can hand parts of the work to the graphics card.
The cheapest path through the pipeline therefore avoids both layout and paint and limits itself to compositing (web.dev). Anyone who designs a motion so that it touches only the last phase saves the browser the expensive geometry recalculation and pixel redraw in every single frame. That is the core of every jank-free animation -- and the reason why the choice of the animated property matters far more than the number of animations.
Only transform and opacity: the compositor thread
There are exactly two properties whose change the browser can handle on the compositing stage alone: transform and opacity (web.dev). The prerequisite is that the moving element sits on its own compositor layer. Every other property pulls work forward in the pipeline -- either a new paint or, more expensively, a full layout pass. So to lift a card on hover, move it with transform: translateY() rather than the top property, and fade it in or out via opacity rather than visibility tricks.
| CSS property | Triggers | Handled on | Cost per frame |
|---|---|---|---|
| transform | composite only | compositor thread | very cheap |
| opacity | composite only | compositor thread | very cheap |
| color, background-color | paint + composite | main thread | medium |
| box-shadow, border-radius | paint + composite | main thread | medium to high |
| width, height, padding | layout + paint + composite | main thread | high |
| top, left, margin | layout + paint + composite | main thread | high |
The reason for this difference lies in the browser's architecture. Compositing can run on its own thread and with support from the graphics card, independent of the heavily loaded main thread. transform and opacity only change how an already drawn layer is positioned or made transparent -- the content of the layer itself stays unchanged (web.dev). Animating width or top, by contrast, forces the browser to recompute the geometry and redraw the affected areas in every frame. On complex pages that is often enough to blow the 10-millisecond budget and produce visible jank. The article on avoiding layout shift shows how closely geometry changes and visual disturbances are linked.
/* Expensive: forces layout and paint in every frame */
.card-slow {
transition: top 200ms ease, width 200ms ease;
}
.card-slow:hover {
top: -8px;
width: 320px;
}
/* Cheap: runs on the compositor thread */
.card-fast {
transition: transform 200ms ease;
}
.card-fast:hover {
transform: translateY(-8px) scale(1.03);
}will-change with intent, not blanket coverage
For an animation to touch only compositing, the element must sit on its own layer. The browser usually creates such layers itself the moment it needs them. With the CSS property will-change you can tell the browser in advance that a certain property is about to change, so it prepares the layer in time. This can smooth the first frame of an animation, because the preparation no longer falls in the middle of the motion. The benefit quickly flips into its opposite, however, if will-change is used too broadly.
will-change is a last resort, not a default
// Set will-change just before the animation and remove it afterwards
const panel = document.querySelector('.panel');
panel.addEventListener('pointerenter', () => {
panel.style.willChange = 'transform';
});
panel.addEventListener('transitionend', () => {
// Release the layer again - saves memory
panel.style.willChange = 'auto';
});The right approach is therefore surgical: apply will-change only to the few elements that actually move, and release the layer again after the animation. For short, infrequent transitions it is often enough to omit will-change entirely, because the browser creates the layer in time anyway. Whether a promotion pays off can be read from the number of compositor layers in the DevTools -- more on that below.
Avoid layout thrashing: read first, then write
Even when animations cleanly use transform, JavaScript can slow down the main thread. A common pattern is the forced synchronous layout: the code changes a style and immediately afterwards reads a layout property such as offsetWidth or getBoundingClientRect (web.dev). Because the browser needs to know the current layout to answer, it recomputes it right away instead of waiting for the next frame. If this happens repeatedly in quick succession -- for instance in a loop -- it is called layout thrashing: the browser recomputes the same geometry over and over and loses precious milliseconds.
// Layout thrashing: reads and writes alternate (slow)
elements.forEach((el) => {
const width = el.offsetWidth; // reading forces layout
el.style.width = width + 20 + 'px'; // writing invalidates layout
});
// Better: first all reads, then all writes
const widths = elements.map((el) => el.offsetWidth); // read only
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.width = widths[i] + 20 + 'px'; // write only
});
});The solution is to separate reads and writes: first gather all measurements, then make all changes (web.dev). That way the browser has to recompute the layout at most once instead of once per element. On large DOM structures a lean structure pays off on top of this -- the fewer nodes the browser has to consider in a layout pass, the faster it finishes. How to achieve that is described in the article on reducing DOM size.
The rule of thumb: batch instead of alternate
requestAnimationFrame: animate in time with the display
Anyone driving a motion in JavaScript should couple it to the display's refresh via requestAnimationFrame. The browser calls the supplied function right before the next repaint and synchronizes it with the frame rate (MDN Web Docs). This has two advantages: the animation runs at the right beat -- roughly every 16.7 milliseconds at 60 Hz, correspondingly more often at 120 Hz -- and the browser automatically pauses the calls as soon as the tab moves to the background. The classic approach with setInterval or setTimeout does not know the frame rate and therefore delivers either too many or too few updates, which shows up as jank.
For effects that depend on scrolling, a related rule applies: the actual visual change belongs in a requestAnimationFrame callback, while the scroll listener itself only records the current value. That way the expensive work runs at most once per frame instead of on every single scroll event. In addition, passive event listeners keep the scroll path clear, because the browser does not have to wait for a possible cancellation of the default behavior. Combined, these techniques let even data-heavy interfaces scroll smoothly.
Smoothness does not come from computing more, but from computing at the right moment and with the right properties. The screen sets the beat -- our job is to have every frame finished before the budget runs out.
Hunting down jank in the DevTools
Jank cannot be guessed; it has to be measured. The performance panel of the browser developer tools records a complete run and shows in the frames track which frames took too long. Red markers and unusually long bars mark dropped frames. In the main thread area, long blocks reveal which phase is losing time -- dark blocks for layout, others for paint. Especially valuable are the notices about a forced layout (forced reflow), which the tool displays directly at the point that causes it (Chrome for Developers).
- Open the performance panel and start a recording during the janky interaction
- Look in the frames track for red markers and long bars -- they mark dropped frames
- Assign long layout and paint blocks in the main thread area to their respective phase
- Watch for forced layout (forced reflow) warnings in the details area (Chrome for Developers)
- Enable paint flashing in the rendering tool to reveal unnecessarily repainted areas
- Check the layers view and keep an eye on the number of compositor layers
The rendering tool helps on top of this: with paint flashing enabled, the browser highlights every repainted area in color -- if half the page flashes during an animation, too much is being redrawn. An FPS meter shows the current frame rate in real time. For animations that needlessly force layout or paint, the Lighthouse audit also reports a corresponding warning about non-composited animations (Chrome for Developers). Together these tools turn a diffuse jank into a concrete, addressable cause -- the starting point of any solid performance analysis.
A typical de-janking path in practice
A recurring pattern illustrates how the measures work together. Starting point: an online shop shows clear jank when its mega menu opens and when hovering over product tiles. The recording in the performance panel reveals dropped frames as soon as the tiles animate. The cause: the hover effect moves the tiles via the top property and simultaneously fades in a soft box-shadow -- both force layout and paint respectively in every frame (project experience). On a long product list this work adds up until the frame budget tears.
The rework starts at three points. First, the hover effect is switched from top to transform: translateY() and the shadow is handled via an already present shadow layer faded in and out with opacity, so the motion only touches compositing. Second, the active tile receives a targeted will-change just before the animation and releases it afterwards. Third, a scroll-dependent effect calculation is moved into a requestAnimationFrame callback. In sum, the interaction runs smoothly at the 60 fps beat again -- without a single feature being removed (project experience). Comparable starting points meet us across shop and website projects, for instance in Shopware performance or with WordPress bottlenecks.
Render smoothness and interactivity complement each other
Keeping animations smooth for good
A good state is not a final state. Every new component, every design update and every additional script can animate an expensive property or slip in a forced layout. That is why render performance belongs in a fixed routine: before every larger release, check the critical interactions in the performance panel and keep the number of animated non-compositor properties low. A performance budget can stipulate that animations use transform and opacity only -- deviations then surface in the review before they go live. That way the smoothness once achieved is preserved across many versions and fits a measurable Core Web Vitals strategy.
The biggest gains usually come where diagnosis and implementation go hand in hand: first make the janky frames visible in the tool, then swap out the offending properties, finally measure the result again. We put exactly this triad into practice in frontend optimization for shops and corporate websites -- embedded in a broader range of performance services. For an initial assessment of the render performance of your most important templates, arrange a no-obligation first consultation.
Sources and Studies