FE-06 — CSS & Rendering Performance
GPU-First Thinking, Compositor Layers, and the Cost Hierarchy
Part of The Production Frontend Engineer series. Prerequisites: FE-00 (rendering pipeline, layout/paint/composite), FE-03 (parse cost, main thread), FE-04 (CLS, INP, Presentation Delay).
Opening
After this chapter, you will never write an animation without knowing exactly which hardware it runs on. You will look at a janky scroll or a stuttering transition and know immediately whether the problem is on the CPU's main thread, the CPU's raster threads, or the GPU's compositor — and you will know this because you understand what each piece of hardware is physically doing at that moment. Most engineers treat CSS performance as a list of rules to memorise: "use transform instead of left, avoid box-shadow, will-change is magic." This chapter is not that. This chapter explains the physical machine underneath CSS so that you can derive the rules yourself — and more importantly, reason about cases the rules don't cover.
Part 1: The Rendering Pipeline as Physical Hardware
You learned the rendering pipeline in FE-00: parse HTML, build DOM, parse CSS, build CSSOM, combine into Render Tree, Layout, Paint, Composite. You need it now at a deeper resolution — not as a sequence of named steps but as a sequence of physical operations happening on specific hardware with specific constraints.
The browser's rendering engine runs across two processors: the CPU and the GPU. The work is distributed between them in a specific way, and that distribution is the entire foundation of CSS performance.
The CPU handles the first four stages: style calculation (which CSS rules apply to which elements), layout (computing pixel positions and sizes), and paint (rasterising elements into pixel bitmaps). These stages run on the main thread and on dedicated raster threads — but critically, they run on the CPU.
The GPU handles the final stage: compositing. It takes the pixel bitmaps the CPU produced, assembles them according to their z-order and transform instructions, and writes the final frame to the display.
CPU (Main Thread) CPU (Raster Threads)
───────────────── ────────────────────
Style Calculation Paint (rasterisation)
Layout ↓
↓ Layer bitmaps in GPU memory
↓
↓──────────────────────────────────────────────────────────
↓
GPU (Compositor Thread)
─────────────────────
Composite layers
Apply transforms
Output final frame → display
The GPU's compositing stage is massively parallel — it was designed to process millions of pixels simultaneously for video games. It runs on its own thread, the compositor thread, completely independently from the main thread. This independence is the key fact behind all CSS performance optimisation: if you can keep work on the GPU's compositor thread, it runs even when the CPU's main thread is completely blocked by JavaScript.
This is why transform and opacity animations survive a 500ms JavaScript garbage collection pause without dropping a frame — the animation is running on the GPU, which does not care what the CPU is doing.
Part 2: Layers — The Bridge Between CPU and GPU
The CPU does not send individual pixels to the GPU for every frame. It sends layers — pre-rasterised rectangular bitmaps — and tells the GPU how to assemble them. This is the layer model, and understanding it is the physical basis for understanding will-change, transform, and why some CSS is free and some is catastrophically expensive.
Think of layers as transparencies stacked on top of each other. Each layer is a bitmap — a grid of RGBA pixel values that the CPU computed during the paint stage. The GPU holds these bitmaps in its own memory (VRAM). To produce a frame, the GPU walks through the layer stack and composites them according to their positions, opacities, and transforms. It can do this in microseconds because GPU hardware is built for exactly this kind of parallel pixel operation.
Here is why this matters for CSS: if the CPU needs to change what is displayed, it has two options.
Option A: Re-rasterise the affected layer (redraw its pixels on the CPU, upload the new bitmap to the GPU). This requires CPU work: style recalculation if styles changed, layout if geometry changed, paint for the new pixels, then a GPU upload. Expensive.
Option B: Tell the GPU to apply a different transform or opacity to an existing layer, without changing the layer's pixels. The GPU has everything it needs — the bitmap is already in VRAM. No CPU work required beyond sending a new instruction. The GPU handles it entirely on the compositor thread. Near-free.
This is the physical reason the rule "use transform instead of top/left" exists. It is not a browser quirk or a historical accident. Moving an element with top: 100px → top: 200px changes the element's geometry — it is a layout operation. The browser must recalculate the layout, repaint the affected area, and produce a new rasterised bitmap. Moving an element with transform: translateY(100px) → transform: translateY(200px) changes nothing the CPU knows about — the layout is identical, the painted pixels are identical — it is purely a GPU instruction to shift an already-composited layer. The GPU applies this in a single frame with no CPU involvement.
Part 3: The Three-Stage Cost Hierarchy
Every CSS property change triggers one of three outcomes. This hierarchy is the most important table in CSS performance:
STAGE TRIGGERED COST PROPERTIES (examples)
─────────────────────────────────────────────────────────────────
Layout + Paint + Composite Expensive width, height, top, left,
margin, padding, font-size,
display, position
Paint + Composite Medium color, background-color,
border-color, box-shadow,
text-decoration
Composite only Cheap transform, opacity
The reason for each tier is physical, not arbitrary.
Layout properties change the geometry of an element — its size or position in the document flow. Because elements in the flow affect each other (a wider element pushes adjacent elements), changing one element's geometry can invalidate the layout of every other element on the page. The browser must run the full layout algorithm again, starting from the element that changed and propagating outward. It must then repaint every affected element (because their pixel positions moved) and then composite. On a complex page with deep nesting, one layout change can trigger a cascade of geometry recalculations across hundreds of elements. This is why layout is expensive.
Paint-only properties change the appearance of an element without changing its geometry. The element stays in the same position, with the same size. The browser only needs to repaint the pixels for that element — it does not need to recalculate any geometry. The rasterised bitmap for that layer is replaced with a new one. Cheaper than layout, but still requires CPU rasterisation work and a GPU upload.
Composite-only properties (transform and opacity) change neither geometry nor pixel content. They change how the GPU assembles existing layers. The CPU is not involved. The compositor thread applies the instruction and produces the new frame. These are the only CSS properties that can be animated at 60fps without involving the main thread at all.
The practical rule follows directly from this hierarchy: animate with transform and opacity. If you cannot use those properties for your specific animation, be aware that you are now involving the CPU's paint pipeline, and on a busy main thread, frames will drop.
Part 4: Layout Thrashing in CSS — The Synchronous Layout Trap
You saw layout thrashing in FE-00 as a JavaScript problem — interleaving DOM reads and writes. The CSS dimension of this problem is different and equally damaging.
Certain JavaScript calls force the browser to perform a synchronous layout calculation — to stop what it is doing on the main thread, run the entire layout algorithm, and return the result before continuing. These are called forced synchronous layouts (or "layout thrashing" when they occur repeatedly).
The triggering calls are any JavaScript operation that reads geometric information from the DOM:
element.offsetWidth // forces layout
element.offsetHeight // forces layout
element.offsetTop // forces layout
element.scrollTop // forces layout
element.getBoundingClientRect() // forces layout
element.clientWidth // forces layout
window.getComputedStyle(element) // forces layout (if geometry is stale)
When you write to a layout property and then immediately read a geometric property, the browser cannot defer the layout calculation — you have demanded the result now. It must flush all pending style and layout work synchronously, before continuing to execute your JavaScript.
// This appears to be 3 independent operations. It is not.
box.style.width = '200px' // marks layout as dirty
const h = box.offsetHeight // forces synchronous layout to answer this question
box.style.height = h + 'px' // marks layout as dirty again
The browser's preferred behaviour is to collect all style mutations during a JavaScript task and perform layout once, at the end, before the next frame. Forcing a synchronous read mid-task breaks this deferral. On each iteration of a loop, this becomes catastrophic:
// Reads and writes alternated — forces layout on every iteration
elements.forEach(el => {
const width = el.offsetWidth // FORCED LAYOUT: flush all pending layout work
el.style.width = width * 2 + 'px' // marks layout dirty again
})
// For 100 elements: 100 forced synchronous layouts in one task
// Result: the main thread spends hundreds of milliseconds in layout
// Result: INP for any interaction during this loop is catastrophic
The fix is to batch reads before writes, respecting the browser's preferred deferred layout model:
// Batch reads first
const widths = elements.map(el => el.offsetWidth) // ONE layout flush, then done
// Then batch writes
elements.forEach((el, i) => {
el.style.width = widths[i] * 2 + 'px' // layout marked dirty but not flushed
})
// Browser performs one layout at the end, before the next frame
The connection to INP (from FE-04): forced synchronous layouts in event handlers are a primary cause of high Presentation Delay in INP. The interaction fires your handler, your handler forces layout, layout blocks the main thread, the next frame cannot paint until layout finishes.
Part 5: will-change — Promoting Elements to Their Own Layer
The browser decides which elements get their own compositor layer based on heuristics. Elements that are animated with transform or opacity are promoted automatically. Complex CSS (3D transforms, filter, certain z-index stacking contexts) also triggers promotion.
will-change is an explicit instruction to the browser: "this element is about to be animated, please promote it to its own compositor layer now." The browser allocates a dedicated layer in VRAM, pre-rasterises the element's pixels into that layer, and is ready to animate it without CPU involvement.
.modal {
will-change: transform;
}
With will-change: transform, when the modal's opening animation starts, the GPU already has the modal's pixels in VRAM as a separate layer. The transform animation runs entirely on the compositor thread. Zero main thread involvement. The animation is smooth regardless of what JavaScript is doing on the main thread.
Without will-change, the browser promotes the element at the start of the animation. This promotion happens on the main thread (because promoting involves rasterising the element's pixels and uploading them to the GPU). On the very first frame of the animation, there is a brief main thread pause while this happens — often visible as a "flash" or a single dropped frame at the start of the animation.
The Cost of will-change — Why You Cannot Use It on Everything
Each promoted layer consumes GPU memory. The element's pixels, in full resolution, reside in VRAM as a bitmap. For a 1920×1080 element, that is 1920 × 1080 × 4 bytes (RGBA) = approximately 8MB of VRAM. Per promoted element.
Modern high-end devices have 4-8GB of VRAM. Mobile devices have far less — often shared with the main GPU framebuffer. If you promote 50 elements with will-change, you could consume hundreds of megabytes of VRAM. On a mobile device, this causes the GPU to start evicting layers to disk — which is slower than not having them in VRAM at all. You have made performance worse by over-optimising.
The correct usage:
- Apply
will-changeonly to elements that are actually animated frequently - Remove it after the animation completes if the animation is triggered by user interaction rather than continuous
// Apply before animation, remove after
button.addEventListener('click', () => {
modal.style.willChange = 'transform'
modal.addEventListener('transitionend', () => {
modal.style.willChange = 'auto' // release the VRAM
}, { once: true })
modal.classList.add('open')
})
will-change is not a performance silver bullet. It is a VRAM allocation instruction. Apply it surgically to elements that need it, and release it when done.
Part 6: content-visibility — Skipping Off-Screen Rendering Entirely
will-change optimises how the GPU handles elements. content-visibility is a different kind of optimisation — it tells the browser not to render elements at all until they are near the viewport.
.article-section {
content-visibility: auto;
contain-intrinsic-size: 0 800px; /* estimated height before content renders */
}
content-visibility: auto instructs the browser to skip layout, paint, and compositing for elements that are outside the visible area (plus a small margin). For a long-form document or a feed with hundreds of sections, this means the browser only renders the sections near the viewport — the others are skipped entirely until the user scrolls toward them.
The physical effect is dramatic. Consider a blog post with 50 sections, each containing images, complex typography, and styled code blocks. Without content-visibility, the browser layouts and paints all 50 sections on page load. With content-visibility: auto, it only renders the first 2-3 sections. Layout time drops from hundreds of milliseconds to tens of milliseconds.
contain-intrinsic-size provides the browser with a placeholder size for sections that have not been rendered yet. Without it, the browser would reserve no space for off-screen sections — the scrollbar would misrepresent the true page length, and sections would suddenly shift the layout into place as they render. contain-intrinsic-size: 0 800px says "assume this section is 800px tall until you actually render it." It is an estimate, not exact, but close enough to prevent jarring layout shifts (a direct connection to CLS from FE-04).
The mechanism under the hood: content-visibility: auto implicitly applies CSS Containment to the element — it establishes a containment context that tells the browser "changes inside this element do not affect the layout outside it, and changes outside do not affect it." This allows the browser to entirely skip internal layout calculations for off-screen elements.
Part 7: CSS Containment — Limiting Layout Scope
CSS Containment is a set of properties that let you tell the browser which parts of your page are independent — changes inside a contained element cannot affect elements outside it, and vice versa.
.card {
contain: layout paint;
}
Without containment, when any property changes inside .card, the browser must potentially recalculate layout for the entire page — because in the general case, a change inside .card could affect adjacent elements (if .card grows, the element below it moves). With contain: layout, the browser knows .card's internal layout changes cannot affect anything outside it. The layout algorithm scope is limited to .card and its children. On a page with 100 cards, this means a content change in one card triggers a layout calculation that is 100× smaller than without containment.
The four containment types:
contain: size — the element's size does not depend on its content. The browser can skip measuring the element's children to determine its size. Strict but rarely applicable (you need to set an explicit size).
contain: layout — the element is a layout boundary. Internal layout changes do not affect outside elements and vice versa. Most commonly useful.
contain: paint — the element's content is clipped to its bounds. The browser knows nothing inside the element is visible outside its border box — it can skip painting children that are outside the element's bounds.
contain: style — CSS counters and quotes inside the element are scoped to it. Rarely used in practice.
contain: strict — equivalent to contain: size layout paint style. The most aggressive containment. Requires an explicit size on the element.
contain: content — equivalent to contain: layout paint style. The most commonly useful combination.
The connection to CSS architecture: if you are building a component library or a complex dashboard with many independent cards, applying contain: content to each card tells the browser that each card is independent. The layout engine can parallelise work across cards and limit the scope of recalculations. On a data-heavy dashboard with dozens of dynamically updating cards, this can reduce layout time by an order of magnitude.
Part 8: Animation — The Complete Decision Framework
Given all of the above, here is the complete framework for making animation decisions from first principles.
Level 1: Can you animate with transform and opacity?
If yes, the animation runs entirely on the GPU compositor thread. Zero main thread involvement. 60fps even if JavaScript is doing heavy work. This is the correct choice for all position, scale, rotation, and visibility animations.
/* Moving an element — compositor only */
.slide-in {
transform: translateX(-100%);
transition: transform 0.3s ease-out;
}
.slide-in.active {
transform: translateX(0);
}
/* Fading an element — compositor only */
.fade {
opacity: 0;
transition: opacity 0.2s ease;
}
.fade.visible {
opacity: 1;
}
Level 2: If you cannot use transform/opacity, which stage does your property trigger?
Paint-triggering properties (color, background, border-color, box-shadow): these involve CPU rasterisation. The main thread must be available. If the main thread is busy (heavy JavaScript, React re-rendering), the animation will drop frames. For infrequent, user-triggered animations (a button changing colour on click), this is acceptable. For continuous animations (a pulsing loading indicator), this may cause frame drops.
Layout-triggering properties (width, height, top, left, margin, padding): avoid animating these. A layout-triggering animation runs the full layout algorithm on every frame. On a complex page, this can block the main thread for 5-15ms per frame — far exceeding the 16ms frame budget. The animation will stutter. The user can feel it.
Level 3: If you must animate a layout property, use requestAnimationFrame and batch mutations
function animateWidth(element, from, to, duration) {
const start = performance.now()
function frame(now) {
const progress = Math.min((now - start) / duration, 1)
const eased = easeInOut(progress)
element.style.width = from + (to - from) * eased + 'px'
if (progress < 1) {
requestAnimationFrame(frame) // schedule next frame, don't block
}
}
requestAnimationFrame(frame)
}
requestAnimationFrame synchronises your mutation with the browser's frame cycle. The mutation happens at the optimal moment in the frame pipeline — after the browser has processed input events and just before it begins layout/paint. This doesn't make layout-triggering properties cheap, but it ensures you are not triggering unnecessary additional layout passes mid-frame.
The FLIP technique — animating layout changes with transforms
FLIP (First, Last, Invert, Play) is a technique for animating layout changes while using only compositor-cheap transform. The idea: measure the element's position before and after the layout change, then animate the transform from the "wrong" position to the "right" position.
function animateLayoutChange(element, change) {
// FIRST: record current position
const first = element.getBoundingClientRect()
// Apply the layout change (this is instant — no animation yet)
change()
// LAST: record new position
const last = element.getBoundingClientRect()
// INVERT: apply a transform that puts the element back at its "first" position
const deltaX = first.left - last.left
const deltaY = first.top - last.top
element.style.transform = `translate(${deltaX}px, ${deltaY}px)`
// Force the browser to acknowledge the inverted position
element.getBoundingClientRect()
// PLAY: animate the transform back to zero (its natural "last" position)
element.style.transition = 'transform 0.3s ease-out'
element.style.transform = 'translate(0, 0)'
element.addEventListener('transitionend', () => {
element.style.transition = ''
element.style.transform = ''
}, { once: true })
}
The layout change happens instantaneously (no animation during layout). The transform animation plays out at compositor speed. From the user's perspective, they see the element smoothly moving from its old position to its new position — but the browser never animated a layout property. This is how libraries like Framer Motion implement layout animations efficiently.
Part 9: The Layers Panel — Seeing the GPU's World
Chrome DevTools has a Layers panel (DevTools → ⋮ → More tools → Layers) that renders the compositor layer structure of your page as an interactive 3D visualisation. Each layer is displayed as a distinct plane, tilted in perspective so you can see the stack.
This tool answers the question: "which elements are promoted to compositor layers, and why?"
For each layer, the panel shows:
- The element that caused the promotion
- The reason for promotion ("Has a will-change property", "Has an accelerated CSS transition", "Overlap with a composited element")
- The layer's memory cost in kilobytes
"Overlap with a composited element" is the most common source of unexpected layer promotions. When element A is promoted to a compositor layer, the browser must also promote any element that might overlap it — because the compositor needs to know the stacking order to assemble them correctly. If your promoted modal is beneath 10 other elements in the stacking order, those 10 elements may also be promoted. This is called layer explosion — a single will-change on one element causes dozens of elements to be promoted, consuming large amounts of VRAM.
The fix: ensure promoted elements are at the top of the stacking order and isolated from other content (using z-index and isolation: isolate to create explicit stacking contexts).
Reading the Layers panel during a performance investigation:
If your page feels slow and you suspect layer explosion, open the Layers panel and look for:
- Total layer count significantly above 10-15 (for a normal page, 5-10 layers is expected)
- Many small layers with the promotion reason "Overlap with composited element"
- Total layer memory cost above 50-100MB
If you see these signs, find the element causing the cascade (usually the one with an explicit will-change or transform animation) and either remove will-change, ensure it is properly isolated from the stacking order, or restructure the DOM to separate the animated element from overlapping content.
Part 10: Practical Audit — How to Find CSS Performance Problems
Given a page with reported visual performance issues (janky scroll, stuttering animation, layout shifts), run this investigation in order.
Step 1: Identify the symptom precisely
Janky scroll → the compositor thread may be involved but layout thrashing is more likely Stuttering CSS animation → animation may be triggering layout or paint Layout shift → a property is changing geometry after initial paint (go to FE-04's CLS workflow) Slow page load / blank screen → CSS is render-blocking (go to FE-04's LCP workflow)
Step 2: Open the Rendering panel
DevTools → ⋮ → More tools → Rendering. Enable:
-
Paint flashing (green overlay on elements being repainted): if you see large areas of the page flashing green during scroll or animation, something is triggering unnecessary repaints. Look for what changed that triggered the repaint — hover states with paint-triggering properties (
box-shadow,background-color) in scroll-heavy areas are common culprits. -
Layout Shift Regions (blue overlay): elements that shifted. Connects directly to CLS diagnosis from FE-04.
-
Compositor layers (orange borders): elements promoted to their own GPU layer. If every element on the page has an orange border, you have layer explosion.
Step 3: Profile the specific interaction
Performance tab → record → perform the janky action → stop.
In the Main thread track, look for:
- Long purple bars labelled "Layout": forced synchronous layout — find which JavaScript call is triggering it
- Long green bars labelled "Paint": expensive repaint — find which CSS property changed
- Short timeline in the Compositor thread: if the compositor thread shows minimal activity during the jank, the problem is on the main thread (expected — fix the layout/paint)
- Dropped frames: grey bars in the Frames track indicate frames that were not painted in time
Step 4: Check the specific property
Confirm which CSS property is being animated or changed. Go through the three-stage hierarchy:
- Layout-triggering? Change to
transform(FLIP if needed) ormargin/paddingtotransform - Paint-triggering? Acceptable for infrequent interactions; use
will-changeto pre-promote if it is continuous - Composite-only? Should be smooth — if not, check for layer explosion (Part 9)
Vocabulary Map
| Term | What it physically is |
|---|---|
| Compositor layer | A pre-rasterised rectangular bitmap of an element's pixels held in GPU memory (VRAM), which the GPU assembles with other layers to produce each frame without CPU involvement |
| Layer promotion | The act of rasterising an element's pixels onto its own dedicated GPU layer — makes transforms and opacity changes free (GPU only) but costs VRAM for the stored bitmap |
| Layout (reflow) | The CPU's calculation of the pixel position and size of every element in the document — can cascade across the entire page when geometry-affecting properties change |
| Paint (rasterisation) | The CPU's process of converting the styled element tree into pixel bitmaps — happens on raster threads, produces the bitmaps that are uploaded to GPU layers |
| Composite | The GPU's process of assembling pre-rasterised layer bitmaps into the final display frame — runs on the compositor thread, independently of the main thread |
will-change | A CSS property that instructs the browser to promote an element to its own compositor layer immediately — allocates VRAM for the element's bitmap before animation begins |
| Forced synchronous layout | A browser behaviour where a JavaScript geometric read (offsetWidth, getBoundingClientRect) forces the browser to flush all pending layout work and return the result synchronously, blocking the main thread |
| Layout thrashing | Repeatedly triggering forced synchronous layout in a loop by alternating DOM writes and geometric reads — causes the layout algorithm to run dozens of times in a single task |
content-visibility: auto | A CSS property that skips layout, paint, and compositing for elements outside the viewport — reduces initial render cost proportional to the amount of off-screen content |
| CSS Containment | A set of CSS properties (contain) that declare element independence — tells the browser that layout and paint changes inside an element cannot affect elements outside it, scoping and parallelising layout recalculation |
| Compositor thread | A browser thread dedicated to compositing GPU layers into frames — runs entirely independently from the main thread, so compositor-only animations survive main thread jank |
| Layer explosion | A condition where one promoted element causes the browser to promote many adjacent elements (to correctly handle stacking order), consuming large amounts of VRAM and potentially degrading performance |
| Paint flashing | A DevTools Rendering overlay that shows green highlights on elements being repainted — used to identify which CSS changes are triggering CPU rasterisation work |
| FLIP animation | A technique (First, Last, Invert, Play) for animating layout changes using compositor-cheap transform by measuring before-and-after positions and animating the difference as a transform offset |
contain-intrinsic-size | A CSS property that provides an estimated size for elements that have not yet been rendered due to content-visibility: auto — prevents incorrect scrollbar representation and layout shifts |
| Stacking context | A three-dimensional rendering context created by certain CSS properties (opacity, transform, will-change, isolation) that groups elements for compositor layer purposes — relevant to layer explosion when promoted elements overlap others |
Practice Problems
Problem 1 — Failure Analysis
A React dashboard has a smooth opening animation on a sidebar (transform: translateX) that suddenly starts dropping frames after a new feature is deployed. The new feature added a notification badge to the sidebar that uses box-shadow and updates its count every 2 seconds via a state change. The transform animation is unchanged. Using what you know about the compositor layer model, compositor threads, and paint-triggering properties, explain the precise mechanism by which the notification badge addition caused the previously smooth animation to drop frames — even though the badge is a separate element from the thing being animated.
Problem 2 — Design from Scratch
You are building a card-based feed (like Twitter or LinkedIn) with the following requirements: 500+ cards rendered in the DOM simultaneously (no virtualisation), each card has a hover animation, cards can be dynamically reordered via drag-and-drop, and the feed must scroll at 60fps on a mid-range Android device. Design the complete CSS performance strategy: which properties you would and would not use, how you would apply CSS containment, whether and how you would use will-change, and how you would handle the drag-and-drop animation without triggering layout on each frame. Justify every decision from the physical layer model.
Problem 3 — Synthesis
A product team reports that their page's INP is 340ms. DevTools profiling shows: Input Delay = 8ms, Processing Time = 12ms, Presentation Delay = 320ms. The interaction is clicking a button that toggles a CSS class, which changes the max-height of an accordion from 0 to 500px. Connect what you know from this chapter (layout-triggering properties, the compositor layer model, the three-stage cost hierarchy) and FE-04 (INP's Presentation Delay component) to explain precisely why a CSS class toggle has 320ms of Presentation Delay, and what two fundamentally different fixes would bring Presentation Delay below 16ms.
Problem 4 — Go Deeper
The GPU's compositor thread runs independently from the CPU's main thread. A transform animation runs on the compositor and should never drop frames even if JavaScript takes 200ms to execute. Yet engineers sometimes report transform animations dropping frames during heavy JavaScript execution. Your knowledge of compositor layers, the raster threads, and the pipeline between CPU and GPU suggests several mechanisms by which this could happen even with a compositor-only property. Reason through at least two distinct mechanisms — neither of which involves the transform property triggering layout or paint — that could cause a transform animation to stutter. What would you look for in DevTools to confirm each hypothesis?
Problem 5 — Previous Chapter Connection (FE-03)
In FE-03 you learned that bundle parse time blocks the main thread — during parse, no JavaScript executes and no user interactions are processed. The same main thread also handles CSS layout and paint. A page loads a 1.8MB (decompressed) JavaScript bundle and simultaneously the browser must layout and paint the initial page content. On a mid-range Android device parsing at 150KB/s, the bundle takes approximately 12 seconds to parse. During this 12-second window, a CSS keyframe animation using background-color is running on a loading skeleton. Explain what the user actually sees during those 12 seconds — specifically, does the skeleton animate smoothly or not, and why? How would the answer change if the skeleton animation used opacity instead of background-color? What does this tell you about the hierarchy of optimisation priorities between bundle size and CSS animation properties?
What's Coming Next
You now understand the physical machine underneath your CSS — the CPU doing layout and rasterisation, the GPU doing compositing, the layer model bridging them, and how every CSS property maps to specific hardware operations. FE-05 goes to the other side of the performance story: the network. You know how bytes arrive at the browser (FE-03 covered the bundle), but there is an entire layer of caching infrastructure between your server and the user that most engineers treat as magic — HTTP headers, CDN edge caches, Service Worker caches, and the browser's own cache hierarchy. Understanding this layer means understanding why a 200KB bundle can be effectively zero cost on repeat visits, why your deployment sometimes shows stale content to users, and how to design a caching architecture that makes your application fast for returning users regardless of their network speed.