Skip to content

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.

js
// 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
js
// 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 needed

Properties that trigger layout (reflow):

  • offsetTop/Left/Width/Height
  • scrollTop/Left/Width/Height
  • clientTop/Left/Width/Height
  • getComputedStyle()
  • getBoundingClientRect()

Fix in practice:

  • Use requestAnimationFrame to batch DOM reads/writes
  • Use a library like fastdom for 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  → CSSOM

Step by step:

  1. Parse HTML → DOM Tree: Browser reads HTML, builds a tree of nodes
  2. Parse CSS → CSSOM: Browser reads CSS, builds a style tree
  3. Merge → Render Tree: Combines DOM + CSSOM (only visible elements)
  4. Layout (Reflow): Calculate exact position and size of each element
  5. Paint: Fill in pixels — colors, borders, text, shadows
  6. 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:

html
<!-- 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:

html
<!-- 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) or translate3d(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:

css
/* 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 texture

Rule: 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)
  • opacity
  • filter (blur, brightness, etc.)

How to promote to GPU:

css
/* 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:

css
/* 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.

css
/* 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):

css
.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:

css
/* 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):

tsx
// 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):

tsx
// 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.

tsx
// 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:

tsx
// 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.

Frontend interview preparation reference.