04 - Browser Rendering & Performance
1. Layout Thrashing
What: Repeatedly reading layout properties (offsetHeight, getBoundingClientRect) and then writing (changing styles), forcing the browser to recalculate layout multiple times in a single frame.
// BAD: Layout thrashing — reads force layout recalculation after each write
for (const el of elements) {
const height = el.offsetHeight; // READ → forces layout
el.style.height = height * 2 + 'px'; // WRITE → invalidates layout
// Next iteration: READ forces layout AGAIN because layout is dirty
}
// If 100 elements → 100 forced layouts in one frame = jank// GOOD: Batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight); // all reads first
elements.forEach((el, i) => {
el.style.height = heights[i] * 2 + 'px'; // all writes after
});
// Only 1 layout calculation neededProperties that trigger layout (reflow):
offsetTop/Left/Width/HeightscrollTop/Left/Width/HeightclientTop/Left/Width/HeightgetComputedStyle()getBoundingClientRect()
Fix in practice:
- Use
requestAnimationFrameto batch DOM reads/writes - Use a library like
fastdomfor automatic read/write batching - Use CSS transforms instead of top/left (transforms don't trigger layout)
2. Critical Rendering Path
What: The sequence of steps the browser takes to convert HTML, CSS, and JS into pixels on screen. Understanding this is key to performance optimization.
HTML → DOM Tree
↘
Render Tree → Layout → Paint → Composite → Pixels
↗
CSS → CSSOMStep by step:
- Parse HTML → DOM Tree: Browser reads HTML, builds a tree of nodes
- Parse CSS → CSSOM: Browser reads CSS, builds a style tree
- Merge → Render Tree: Combines DOM + CSSOM (only visible elements)
- Layout (Reflow): Calculate exact position and size of each element
- Paint: Fill in pixels — colors, borders, text, shadows
- Composite: Combine painted layers into the final image
Optimizing the critical path:
- Minimize critical resources (CSS/JS that block rendering)
- Minimize critical bytes (compress, minify)
- Minimize critical path length (reduce round trips)
3. Render Blocking Resources
What: Resources that prevent the browser from rendering anything until they're fully loaded and processed.
CSS is render-blocking by default:
<!-- Browser won't paint ANYTHING until this CSS is fully downloaded + parsed -->
<link rel="stylesheet" href="styles.css">
<!-- Fix: Media queries make it non-blocking for non-matching conditions -->
<link rel="stylesheet" href="print.css" media="print">
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)">JS is parser-blocking by default:
<!-- Browser stops parsing HTML when it hits this -->
<script src="app.js"></script>
<!-- Fix: async — downloads in parallel, executes when ready (may execute before DOM complete) -->
<script async src="analytics.js"></script>
<!-- Fix: defer — downloads in parallel, executes after HTML parsing complete, in order -->
<script defer src="app.js"></script>async vs defer:
HTML: ──────█████──────████████──────────────
↑ parse stops for async script
async: ════════╗ (download) ║ execute (order not guaranteed)
defer: ════════╗ (download) ║ execute (after HTML, in order)Use defer for app code (needs DOM). Use async for independent scripts (analytics).
4. Browser Compositing Layers
What: The browser can split the page into independent layers that are composited (overlaid) by the GPU. Moving/animating a layer doesn't require repaint of other layers.
What creates a new layer:
transform: translateZ(0)ortranslate3d(0,0,0)(hack)will-change: transform(proper way)position: fixed<video>,<canvas>,<iframe>- CSS
filter,backdrop-filter - Elements overlapping an already-composited layer
Why layers matter for animation:
/* BAD: Animating top/left triggers layout + paint every frame */
.box { position: absolute; top: 0; transition: top 0.3s; }
.box.moved { top: 100px; }
/* GOOD: Transform only triggers composite — GPU handles it */
.box { transition: transform 0.3s; }
.box.moved { transform: translateY(100px); }Too many layers = "layer explosion": Each layer uses GPU memory. Creating thousands of layers (e.g., every list item) can actually hurt performance. Use will-change sparingly and only during animation.
5. Paint vs Composite vs Layout
Three phases of rendering, from most expensive to least:
Layout (Reflow) — MOST EXPENSIVE
├── What changes: width, height, margin, padding, position (top/left),
│ display, font-size, content changes
├── What happens: recalculates geometry of ALL affected elements
├── Cascades: one element's size change can affect siblings, parent, children
└── Example: changing `width` recalculates the entire flow
Paint — MEDIUM EXPENSIVE
├── What changes: color, background, border-color, box-shadow, visibility
├── What happens: fills pixels for the affected layer
├── No cascade: only the changed element's layer is repainted
└── Example: changing `background-color` only repaints that element
Composite — CHEAPEST (GPU-only)
├── What changes: transform, opacity
├── What happens: GPU moves/adjusts existing painted layers
├── No reflow, no repaint
└── Example: `transform: scale(1.5)` just tells GPU to scale the textureRule: Animate only transform and opacity for 60fps animations.
6. GPU Acceleration in CSS
What: Offloading visual operations to the GPU for smoother performance. The GPU is massively parallel and excels at pixel operations.
Properties handled by GPU (composite-only):
transform(translate, rotate, scale, skew)opacityfilter(blur, brightness, etc.)
How to promote to GPU:
/* Tells browser: "this element will animate, prepare a GPU layer" */
.animated-card {
will-change: transform;
}
/* Remove after animation is done to free GPU memory */
.animated-card.idle {
will-change: auto;
}CSS transitions/animations on GPU:
/* 60fps smooth — GPU-only properties */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s, transform 0.3s;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}7. CSS Containment
What: The contain CSS property tells the browser that an element is isolated from the rest of the page, enabling rendering optimizations.
/* contain: layout — element's internals don't affect outside layout */
/* contain: paint — element's children don't paint outside its bounds */
/* contain: size — element's size isn't dependent on children */
/* contain: style — counters/quotes don't escape this element */
/* Most useful shorthand: */
.card {
contain: content; /* = layout + paint (but not size) */
}
/* Most aggressive: */
.widget {
contain: strict; /* = layout + paint + size + style */
/* Browser can skip this entirely during layout if it's offscreen */
}content-visibility: auto (the killer feature):
.offscreen-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* estimated height for layout */
}
/* Browser completely skips rendering offscreen sections.
Like virtual scrolling but for FREE with CSS.
Can reduce initial render time by 50-90% on long pages. */Why it matters: Long dashboard pages with many charts/tables — sections below the fold don't need to be rendered until scrolled into view.
8. Subpixel Rendering
What: Browsers can position and size elements at fractional pixel values. This causes anti-aliasing at edges, blurry text, and 1px gaps between elements.
Element at left: 10.5px, width: 100.3px
Browser must decide: round to 10px or 11px? Width 100px or 101px?
Different browsers round differently → layout inconsistencies.Common issues:
- Blurry text: Font rendered at fractional position → anti-aliased edges
- 1px gaps: Two adjacent elements whose widths don't sum to a whole pixel
- Jittery animations: Transform to fractional values → flickers
Fixes:
/* For animations: use whole pixel values or percentages */
transform: translate3d(0, 0, 0); /* force GPU layer, avoids subpixel issues */
/* For layout: avoid calc() that produces fractions */
/* BAD */
width: calc(100% / 3); /* = 33.333...% → subpixel */
/* BETTER for grids */
display: grid;
grid-template-columns: repeat(3, 1fr); /* browser handles rounding correctly */9. Deterministic Rendering
What: Given the same input (state + props), the UI should always produce the exact same visual output, regardless of when or how many times it renders.
Why it matters:
- Server-rendered HTML must match client-rendered HTML (hydration)
- React StrictMode double-renders in dev to catch non-deterministic renders
- Concurrent mode may render components multiple times before committing
Non-deterministic rendering (bugs):
// BAD: Different output each render
function Component() {
return <p>Rendered at: {Date.now()}</p>; // different every render
}
// BAD: Random values in render
function Component() {
return <p>ID: {Math.random()}</p>; // different every render
}
// BAD: Reading from mutable external source
let counter = 0;
function Component() {
counter++; // side effect in render!
return <p>Renders: {counter}</p>;
}Deterministic rendering (correct):
// State and props are the ONLY inputs to rendering
function Component({ name }: { name: string }) {
return <p>Hello, {name}</p>; // same name → same output, always
}
// Side effects go in useEffect, not in render
function Component() {
const [time, setTime] = useState<number | null>(null);
useEffect(() => setTime(Date.now()), []);
return <p>Loaded at: {time ?? '...'}</p>;
}10. Idempotent UI Actions
What: An action that produces the same result regardless of how many times it's executed. Clicking "Save" once or five times should produce the same saved state.
Why it matters: Network retries, double-clicks, React StrictMode double-invocation.
// NON-IDEMPOTENT: Each click adds another item
const handleAdd = () => {
setItems([...items, newItem]); // clicking 3x adds 3 copies
};
// IDEMPOTENT: Uses unique ID, adding same item twice has no effect
const handleAdd = () => {
setItems(prev => {
if (prev.some(item => item.id === newItem.id)) return prev; // already exists
return [...prev, newItem];
});
};API design for idempotency:
// Idempotency key prevents duplicate payments
const handlePay = async () => {
const idempotencyKey = `pay_${orderId}_${Date.now()}`; // generated once
await api.processPayment({
orderId,
amount,
idempotencyKey, // server deduplicates by this key
});
};In fintech: Idempotency is critical. A double-charge on a payment is catastrophic. Every payment API should accept an idempotency key.