A useful mental model: the browser works in layers, a bit like Photoshop or Figma. It slices the page into composited layers, uploads them to the GPU as textures, and then either shuffles those textures around with cheap GPU math or throws them away and repaints them when their content changes. Almost every animation performance question reduces to one thing – can the browser move an existing texture, or does it have to redraw?
The Rendering Pipeline
When something on the page changes, the browser walks through up to four stages:
Style → Layout → Paint → Composite
The further down the pipeline you can stay, the cheaper the frame. Style recalculation figures out which rules apply and what the computed values are. Layout works out geometry. Paint produces pixels. Composite assembles the result. Skipping the first three is the whole game.
1. Layout (Reflow)
Pure geometry math: where each box sits, how big it is, how it pushes its neighbours around. Change a width high in the tree and the browser recalculates positions for that whole subtree – sometimes for the entire document.
Layout cost scales with the number of affected elements and the depth of the tree. On a big page it’s the most expensive thing you can trigger, and it’s alarmingly easy to trigger it 60 times a second by accident.
Triggers Layout:
width,height,margin,padding,border-widthtop,left,right,bottomon positioned elementsfont-size,line-height- Reading
offsetWidth,offsetHeight,scrollTop,getBoundingClientRect(),getComputedStyle()
Those reads force a synchronous layout if anything geometric changed since the last frame. Alternating writes and reads in a loop is layout thrashing, and it’s one of the most common causes of jank in hand-written animation code. Batch all your reads, then all your writes.
2. Paint (Repaint)
The browser rasterizes elements into bitmaps: colours, borders, shadows, text, backgrounds.
Flat fills are cheap. Gradients, large blurs, shadows, filters, and dense text are not. Paint cost scales with the painted area, so a full-viewport element with a soft shadow costs far more than a small card with the same shadow.
Triggers Paint:
color,background-color,background-imagebox-shadow,text-shadow,border-radiusoutline,visibility
3. Composite
The GPU takes the already-rasterized layer textures and combines them. This is matrix math in hardware, it’s the cheapest stage by a wide margin, and it’s where you want every per-frame animation to live.
Triggers Composite only:
transform(translate, scale, rotate, skew)opacity
There’s a bonus here that’s easy to miss. transform and opacity animations declared in CSS or created with the Web Animations API can be handed to the compositor thread entirely. They keep running smoothly even while the main thread is stuck in a long task. The identical animation driven by JavaScript setting style.transform every frame cannot do that – it depends on the main thread by definition, so a 200 ms task will freeze it.
Worth knowing: compositor handoff isn’t guaranteed. Chrome will refuse to composite an animation in various situations – a layer that’s too large for the GPU’s texture limits, certain filter or clip combinations, or a transform animated on an element that also needs main-thread work each frame. Chrome DevTools flags these cases in the Animations panel and in the Performance trace, so check rather than assume.
Compositing Layers
The browser doesn’t repaint the page 60 times a second. It splits the page into compositing layers, rasterizes each one, and uploads it to the GPU. After that, transform: translateX(100px) just relocates a texture. Nothing is repainted.
What promotes an element to its own layer
will-change: transform,will-change: opacity,will-change: filter- A running CSS animation or transition on
transformoropacity <video>,<canvas>,<iframe>position: fixedandposition: sticky(usually promoted, not guaranteed)filter,backdrop-filter- 3D transforms (
translate3d,translateZ)
Promotion is a heuristic, not a contract, and the heuristics differ between Chromium and WebKit and change between releases. Don’t guess – open the Layers panel or turn on “Layer borders” in the Rendering tab and look.
The memory bill
A layer’s texture costs roughly width × height × DPR² × 4 bytes.
- 1920×1080 CSS px at DPR 1 ≈ 8 MB
- 1920×1080 at DPR 2 ≈ 33 MB
- A 390×844 phone viewport at DPR 3 ≈ 12 MB
Real allocations are tiled and depend on the compositor’s internals, so treat these as order-of-magnitude figures. There’s no published VRAM budget either; it depends on the device, the driver, and the browser build, and mobile budgets are dramatically tighter than desktop ones.
The practical point is that every promoted layer holds memory for as long as it exists. A handful of full-screen, high-DPR layers is enough to push a budget Android phone into evicting and re-uploading textures, which you’ll see as stutter and brief blank flashes during scroll.
Shadows
box-shadow is a paint cost, and it grows with blur radius and element area. Painted once on a static element, it’s a non-issue – it’s part of a bitmap that already exists.
Shadows plus transform animations
If the element sits in its own compositing layer, the shadow is baked into the layer texture. Moving the element is then pure compositing.
.card {
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
transition: transform0.3s;
}
.card:hover {
transform: translateY(-10px);
}
The shadow is rasterized once. On hover, the GPU slides the whole texture, shadow included. No repaint.
Don't animate the shadow itself
/* Bad */
.card {
transition: box-shadow 0.3s;
}
.card:hover {
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
}
Blur, spread, and offset change every frame, so the shadow is re-rasterized every frame. On a grid of cards under the cursor this is very visible.
If you want a shadow that appears to grow, cross-fade to a pseudo-element that already has the larger shadow painted:
.card::after {
content: '';
position: absolute;
inset: 0;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.card:hover::after {
opacity: 1;
}
Both shadows are painted once; only opacity changes per frame.
Summary
Pros:
- Static elements – Fine, painted once
- Alongside a transform animation – Fine, the shadow rides in the layer
Cons:
- Animating blur, spread, offset, colour – Repaint every frame, avoid
- Very large elements with very large blur – Expensive even once, avoid
Scroll and Pointer Handlers
The problem
window.addEventListener('scroll', () => {
element.style.top =window.scrollY +'px';
// disaster
});
Writing top triggers layout, so you’re relaying out the page on every scroll tick. Read geometry in the same handler and you add a forced synchronous layout on top of that.
One thing worth correcting if you learned this a while ago: modern browsers already align scroll, wheel, and pointer-move dispatch to the frame loop and coalesce the events. You generally get about one event per frame, not hundreds per second. That does not save you here. One layout per frame is more than enough to blow the budget.
The fix: requestAnimationFrame plus transform
let scrollY = 0;
let ticking = false;
window.addEventListener('scroll', () => {
scrollY = window.scrollY;
if (!ticking) {
requestAnimationFrame(updateElements);
ticking = true;
}
}, { passive: true });
function updateElements() {
element.style.transform = `translateY(${scrollY}px)`;
ticking = false;
}
What this buys you:
- The handler only reads
scrollY(cheap) and flips a flag. requestAnimationFramecaps you at one DOM write per frame even if events arrive faster.- Writes happen immediately before rendering, with no read/write interleaving.
transformkeeps the work in the compositor.
About { passive: true }
The advice you’ll see everywhere is “always add { passive: true } to scroll, touch, and wheel listeners.” It’s harmless, but in 2025 it’s mostly cargo cult:
- The
scrollevent is not cancelable, sopassivechanges nothing there. touchstart,touchmove, andwheellisteners registered onwindow,document, ordocument.bodyare already passive by default in Chromium, Firefox, and Safari.
Where it still matters is listeners attached to a specific scrollable element – a carousel track, a modal body, a custom slider. Those default to non-passive, and a slow handler there will delay scrolling. Add { passive: true } explicitly, and if you genuinely need preventDefault(), use touch-action: none in CSS to declare intent instead of blocking the compositor.
Prefer scroll-driven animations where you can
If all you need is motion tied to scroll position, CSS scroll-driven animations do it off the main thread:
@supports (animation-timeline: scroll()) {
.progress {
animation: grow auto linear;
animation-timeline: scroll(root);
}
}
Support today: Chrome and Edge 115+, Safari 26+. Firefox has it implemented but still behind a preference, so keep a fallback and declare animation-timeline after the animation shorthand, since the shorthand resets it.
Pointer handlers
Same pattern, same reasoning:
let mouseX = 0, mouseY = 0;
let ticking = false;
document.addEventListener('pointermove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
if (!ticking) {
requestAnimationFrame(updateCursor);
ticking = true;
}
});
function updateCursor() {
cursor.style.transform = `translate(${mouseX}px, ${mouseY}px)`;
ticking = false;
}
Use pointermove rather than mousemove unless you need legacy support – it covers mouse, touch, and pen, and it’s coalesced per frame. Only reach for pointerrawupdate if you’re building something like a drawing app that genuinely needs sub-frame input resolution.
Video and Memory
Decoded frames don’t sit in memory as RGBA. Hardware decoders typically output YUV 4:2:0, about 1.5 bytes per pixel. So a 1080p frame is roughly 3 MB decoded and a 4K frame roughly 12 MB, and the browser keeps only a handful of frames in flight – never the whole video.
The pipeline:
- Decode – compressed frames go through a hardware decoder where one is available.
- Upload – the current frame lands in a GPU texture.
- Composite – the GPU blends that texture with the rest of the page.
- Release – old frames are freed and the decoder moves on.
That’s why a 4K video plays fine on a phone with a modest memory budget. What actually hurts is many videos at once. Each holds a decoder session, a compositing layer, and a continuous stream of decode and upload work. Hardware decoder instances are a limited resource; past the limit the browser falls back to software decoding on the CPU, which is exactly when phones get hot, batteries drain, and frames start dropping.
Do browsers stop off-screen videos automatically?
Mostly no. A playing <video> scrolled out of the viewport generally keeps decoding.
The exceptions:
- iOS Safari pauses
<video autoplay muted playsinline>when it scrolls out of the viewport, and resumes it when it comes back. That’s a documented WebKit autoplay policy, not general behaviour. - All browsers throttle or suspend decoding in fully hidden tabs, which is a different thing from an off-screen element in a visible tab.
Don’t rely on either. Manage playback yourself.
Intersection Observer
const videoObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.play().catch(() => {});
} else {
entry.target.pause();
}
});
}, { threshold: 0.25 });
document.querySelectorAll('video').forEach((video) => {
videoObserver.observe(video);
});
The .catch() matters: play() returns a promise that rejects if autoplay is blocked or the element is paused again immediately, and unhandled rejections will spam your console.
Pair this with preload="none" (or "metadata") and lazy src assignment so off-screen videos never download in the first place. On a page with more than two or three videos, this is usually the single biggest win available.
Class Toggles and Cascade Cost
Adding or removing a class triggers style recalculation – the browser works out which rules now apply.
document.body.classList.add('dark-mode');
body.dark-mode .card {
background: #1a1a1a;
}
body.dark-mode .button {
color: white;
}
Modern engines use invalidation sets, so they don’t blindly recompute every descendant. But a class on <body> that participates in descendant selectors does invalidate broadly, because a large part of the tree genuinely might match. Roughly, cost is the number of affected elements multiplied by the selectors that have to be evaluated against them. DevTools shows you both: “Recalculate Style” in the Performance panel, and Selector Stats for which selectors are actually eating the time.
Fine: theme switching, opening a modal, one-off state changes. Nobody notices 4 ms once.
Not fine: doing it in a scroll handler, a pointer handler, or anything per frame. If you must change state every frame, change it on the smallest element you can get away with, never on html or body. Better still, animate a CSS custom property and let the cascade read it:
.card {
transform: translateY( calc(var(--scroll-offset,0) * 1px));
}
Registered custom properties (@property) can even be transitioned and, when they only feed transform or opacity, may stay off the main thread.
:has() and :not()
Short version: :not() is essentially free. :has() scoped to a small component is fine. :has() on html or body can be genuinely bad.
:has() looks downward into subtrees, which inverts the engine’s usual assumptions: a mutation deep in the tree can invalidate a rule matched on an ancestor. Chromium and WebKit both ship dedicated invalidation logic for this and it’s far better than it was in 2022, but the scope of the subject still decides everything.
Good:
.card:has(>.badge) {
padding-top: 40px;
}
Small subject, narrow scope, child combinator instead of a full descendant walk.
Bad:
body:has(.modal-open) {
overflow: hidden;
}
html:has(.something) {
/* ... */
}
Now every mutation anywhere in the document is a candidate for re-evaluating this rule. Toggle a class from JavaScript instead – it’s one line and it costs nothing.
Also bad: :has() in anything that changes per frame. Scroll handlers, drag interactions, animation loops. Wrong tool.
DOM Size and Nesting
Short version: fifteen wrapper divs deep across 10,000 nodes is a real problem. A flat-ish tree around five levels deep with a couple of thousand nodes is fine.
Style and layout cost scale with node count and tree depth, and every wrapper is one more box to measure. Lighthouse’s DOM size audit warns at roughly 800 elements and errors at 1,400 – not laws of physics, but a reasonable sanity bar.
Guidelines:
- Keep node count modest, and be stricter on mobile than on desktop.
- Keep nesting shallow. Delete wrappers whose only job is holding a class.
- Virtualize long lists rather than rendering 5,000 rows.
- Use
containorcontent-visibilityto isolate independent blocks.
will-change
will-change is a hint, not a performance mode. It buys you pre-emptive layer promotion in exchange for memory held indefinitely.
Correct usage
Set it right before the animation, drop it right after:
element.style.willChange = 'transform';
const anim = element.animate(
[
{ transform: 'translateX(0)' },
{ transform: 'translateX(100px)' }
],
{
duration: 300,
easing: 'ease-out'
}
);
anim.finished.then(() => {
element.style.willChange = 'auto';
});
Or declare it only in the state that precedes the animation:
.card:hover {
will-change: transform;
}
That works because hover typically precedes the click or transition you care about. It is not a magic bullet – if the promotion happens in the same frame as the animation start, you’ve gained nothing.
Incorrect usage
Permanently on:
.element {
will-change: transform;
}
Memory reserved forever for a layer that may never animate.
On many elements at once – this is the one that actually breaks devices. A hundred cards at 500×500 CSS px on a DPR-2 screen is about 4 MB of texture each, so roughly 400 MB permanently allocated. That’s how you ship a buttery animation that crashes phones.
When to skip it entirely
For ordinary CSS transitions on transform and opacity, the browser promotes the element for the duration of the animation on its own:
.button {
transition: transform 0.3s;
}
.button:hover {
transform: scale(1.1);
}
No will-change needed. Reach for it only after you’ve measured a visible hitch on the first frame.
contain and content-visibility
contain tells the browser how far the effects of a change can propagate.
contain: layout
.list-item {
contain: layout;
}
Changes inside the element can’t affect layout outside it, so invalidation stops at the boundary instead of climbing to the root. On a long list, that’s the difference between relaying out one row and relaying out the whole list.
contain: content
Shorthand for layout paint style. A sensible default for independent blocks, because it deliberately leaves out size containment – the element still sizes to its content.
.grid-card {
contain: content;
}
One caveat that bites people: paint containment clips to the padding box. Shadows, tooltips, dropdowns, and focus rings that escape the element will be cut off. If a component has anything that overflows on purpose, use contain: layout alone.
content-visibility: auto
.section {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}
Off-screen sections skip layout, paint, and rendering of their subtree until they approach the viewport. On long pages this can cut initial render time dramatically.
Always pair it with contain-intrinsic-size, otherwise off-screen sections collapse to zero height and the scrollbar jumps as you scroll. The auto keyword tells the browser to remember the real size once it has measured it and use your value only as the initial guess.
Two things to know about the current state of this feature: skipped content in Chromium is reachable by find-in-page, anchor links, and tab focus (the browser forces it visible), so it’s not the accessibility hazard it was when it first shipped. But content-visibility is animatable now via transition-behavior: allow-discrete, and mixing that with auto on the same element is a good way to confuse yourself. Pick one job per element.
Checklist
- Animate
transformandopacityonly, in anything that runs per frame. - Prefer CSS or the Web Animations API so animations can live on the compositor.
- Route JavaScript-driven updates through
requestAnimationFrame: reads first, writes second, never interleaved. - Add
{ passive: true }to touch and wheel listeners on specific elements; know that it’s already the default onwindowanddocument, and meaningless forscroll. - Pause and unload off-screen videos with Intersection Observer, and set
preload="none". - Never animate
width,height,margin,top,left, orbox-shadow. Cross-fade a pseudo-element instead. - Keep
:has()scoped to small components. Never put it onhtmlorbody. - Use
contain: contentorcontent-visibility: autoon independent blocks, and remember the clipping caveat. - Add
will-changeimmediately before an animation and remove it immediately after. Never leave it on dozens of elements. - Keep the DOM shallow and small, and virtualize long lists.
- Measure in DevTools (Performance, Layers, Rendering, Animations) on a mid-range Android phone with CPU throttling on – not on your M-series laptop, where everything looks fine.
Lead Developer at Bersus
Alex is a seasoned expert with 9 years of experience in WebGL, WordPress, VR/AR, and ecommerce, turning complex (and sometimes crazy) ideas into seamless, user-friendly solutions with passion and attention to detail.

