Skip to main content

Command Palette

Search for a command to run...

Web Performance Deep Dive: From Browser Internals to Best Practices

My notes after spending 7 hours studying.

Updated
14 min read
Web Performance Deep Dive: From Browser Internals to Best Practices

Key Takeaways for writing performant web code

I'm just gonna link this if you're not convinced why performance matters: Why speed matters.

These are my notes to be clear from studying how the browser works and performance.

Initial render

  1. Load and Parse HTML:

    • Browser receives HTML text

    • Parses HTML into DOM tree

    • When it hits <script> without defer or async: stops parsing

    • When it hits <link rel="stylesheet">: continues parsing but blocks rendering

  2. Load and Parse CSS:

    • CSS loading starts as soon as <link> is found (happens in parallel with HTML parsing)

    • CSSOM construction is blocking:

    • CSS selectors are matched right-to-left for efficiency

      • e.g. in "div.box p", it works backwards from the element

      • Quote from Stackoverflow link which made it click for me: "This is because as the browser is scanning the page it just needs to look at the current element/node and all the previous nodes/elements that it has scanned."

  3. JavaScript Execution:

    • Normal <script>: blocks HTML parsing until downloaded & executed fklsdkfdl;sksl

    • async: downloads in background, executes as soon as ready

    • defer: downloads in background, executes after HTML parsing

  4. Create Render Tree (also known as Layout Tree):

    • Combines DOM and CSSOM

    • Excludes non-visual elements (meta, script, etc.)

    • Excludes display: none (but keeps visibility: hidden, since it still occupies space in the layout)

  5. Layout:

    • Calculate exact positions and sizes

    • Triggered by viewport size and content

  6. Paint:

    • Convert layout into actual pixels

    • Follows painting order (background → borders → text → outlines), this is important (think of you yourself painting a picture, if text first, then background, the text would NOT be visible)

  7. Composite:

    • Split content into layers

    • GPU combines layers for final display

Events:

  • DOMContentLoaded: DOM ready, but images/resources might not be. DOMContentLoaded doesn't directly wait for stylesheets to be ready. However, it does wait for all deferred scripts to have downloaded and executed. Deferred scripts themselves wait for stylesheets to be ready. Therefore, often, DOMContentLoaded is triggered when stylesheets are ready. If you do NOT have any deferred scripts (usually you do if you're writing JS), then DOMContentLoaded is triggered when the DOM is ready.

  • load: Everything loaded including images/resources

Rendering Pipeline

  1. Style: Calculate the styles that apply to the elements.

  2. Layout: Generate the geometry and position for each element.

  3. Paint: Fill out the pixels for each element.

  4. Composite: Separate the elements into layers and draw the layers to the screen.

These steps are sequential. If you animate something that changes layout, it will start from the layout step, then do paint and composite.

What's a layer?

A layer is like a transparent sheet that the browser can move around independently. Think of it like this:

  1. Without layers (basic page):
// Everything is painted on one surface
// To move something, browser must repaint everything
element.style.left = "100px"; // Expensive! Repaint all
  1. With layers:
// Element gets its own "sheet"
// Browser can move just that sheet using GPU
element.style.transform = "translateX(100px)"; // Cheap! Just move the layer

Elements that automatically get their own layer:

  • Elements with 3D transforms (transform: translate3d)

  • and elements

  • Elements with opacity animations

  • Elements with fixed position

  • Elements with CSS filters

will-change to create layers

For most animations, you should use transform and opacity, they automatically get their own layers and are GPU-accelerated (just moving layers, like moving transparent sheets on a projector).

will-change exists but is rarely needed! The browser is good at managing layers automatically.

Examples:

/* GOOD: Uses transform - automatically gets a layer */
.slide-menu {
  transform: translateX(-100px);
  transition: transform 0.3s;
}

/* BAD: Don't add will-change just because you're animating */
.menu {
  will-change: transform; /* Unnecessary! */
  transform: translateX(-100px);
}

Key points:

  • Browser handles layers automatically for transform/opacity

  • Each layer costs GPU memory

  • will-change is a last resort, not a performance booster

  • If you think you need will-change, try refactoring to use transform/opacity first

  • In practice, you almost never need to use will-change

The truth is, it's hard to give a good example of when to use will-change because most cases where you might think to use it should be solved with transform/opacity instead!

General notes

1. Script Loading

  • Use defer by default for your scripts.

  • Save async for analytics/monitoring where early execution matters.

  • Both avoid blocking HTML parsing during download. async will block during execution. That's why it's useful for monitoring scripts you want to run ASAP.

  • defer maintains script order and waits for DOM.

2. Animation Performance

  • Use transform and opacity for animations

  • These properties get their own layers and only trigger compositing (not the full rendering pipeline).

  • Avoid animating top, left, width, height - they trigger full layout

  • Use CSS animations over JavaScript when possible - they run on compositor thread

3. Layer Management

  • Elements with transform/opacity automatically get layers

  • Use will-change wisely for elements you'll animate (you'll almost never need it!)

  • Don't create too many layers (each uses memory)

  • Chrome DevTools Layers panel helps debug this

4. Event Handling

  • Use passive: true for scroll/touch listeners when not preventing default. This tells the browser upfront that you're not going to prevent the default action. It doesn't block scroll performance of the page.

  • Avoid document-level event listeners (makes whole page non-fast scrollable)

  • Use touch-action CSS instead of JS touch handlers when possible

  • Event delegation is convenient but makes entire surface non-fast scrollable (e.g. if you have a top element listening for event from its children and inside it, you check which element was clicked to determine what to do)

5. Rendering Pipeline Awareness

  • Style changes → Layout → Paint → Composite

  • Some properties trigger all steps (expensive)

  • Others only trigger paint or just compositing (cheaper, must for animations)

  • Breaking up heavy JS work with requestAnimationFrame

  • Moving CPU heavy work to Web Workers to keep main thread free (caveat: you can't use DOM in Web Workers)

Regarding requestAnimationFrame and the "why" behind it:

// Bad: Blocks main thread continuously
function animate() {
  element.style.left = "100px";
  // Other expensive work...
}

// Better: Browser can schedule the frame when ready
requestAnimationFrame(() => {
  element.style.left = "100px";
  // Still blocks, but at the right time
});

What does the "right time" mean?

This is a bit tricky to understand. I say this for myself lol.

But it's hard to understand because a lot of explanations are vague with the real "why" behind it.

First things, the browser paints the screen at a certain rate. This is called the "refresh rate". For most devices, this is 60 times per second. Which is typically every ~16.7ms.

This interval is NOT exact, and may be longer or shorter depending on what's happening on the page and work needed to be done.

Ths is when the browser "paints". This is the ONLY time the browser "updates" the UI.

You often here "jank" if you don't use requestAnimationFrame. This means it's not smooth. It's a jumpy animation. It feels like "lag".

But WHY?

// Reality of browser paint cycles:
Paint 1: at ~16ms
Paint 2: at ~33ms
Paint 3: at ~49ms  // Not always exact intervals

// Our code without requestAnimationFrame:
update position to 10px  // at 17ms
update position to 15px  // at 17.1ms
update position to 20px  // at 17.2ms

// At next paint (~33ms):
Browser: "OK time to paint! What's the latest state?"
         "Ah, position is 20px"
         // Only shows final position,
         // intermediate values (10px, 15px) never shown

Let's say we're updating the UI animation before the paint happens.

When the browser paints, it looks at the latest state of the UI.

When we do out update e.g. element.style.left = '100px', it just updates it in memory at that split millisecond.

During the time of paint is the ONLY time the browser updates the UI. The browser looks at the latest state of the UI and goes "OK, you're now 20px, this is what I'm gonna show to the user".

The reason jank happens is first and foremost, the browser's interval is not exact. Second of all, your animation may vary. Maybe sometimes it happens quicker than others for whatever reason.

PS. I wanna be very clear and say that 16.7ms for our human eyes is VERY fast. So when you hear "60 times per second", it at first doesn't seem like a lot. But it is. And is usually enough.

So the underlying "why" is that the animation should happen exactly when the browser paints.

This way you don't "skip" animations for your users. Otherwise it looks "jumpy".

6. Scrolling Optimization

Chrome's Tiling

  • Instead of keeping the whole page in memory, Chrome splits it into tiles (think of it like a grid of pieces)

  • Only keeps tiles near your viewport in GPU memory. No need to keep it all!

  • As you scroll, loads new tiles ahead of time

  • Drops far away tiles to save memory

  • This is why smooth scrolling works even on long pages!

It's also why if you have a long page and scroll SUPER fast, you might see some blank tiles at times.

IntersectionObserver vs Old School Infinite Scroll

// Old way (bad):
window.addEventListener("scroll", () => {
  // Runs on EVERY scroll pixel
  if (nearBottom()) loadMore();
});

// Better way:
const observer = new IntersectionObserver((entries) => {
  // Only runs when element comes into view
  if (entries[0].isIntersecting) loadMore();
});
observer.observe(loadingElement);

CSS Contain

CSS Contain is another difficult concept to understand.

I'm gonna try to simplify it, pardon me if I fail lmao.

Generally, you want to use contain to isolate parts of your page. Parts you know won't affect other parts of the page, AT ALL.

Do you remember how we used passive: true for scroll/touch listeners? How we communicate upfront what our intentions are?

It's kind of the same thing.

Without contain:

.card {
  /* When something changes in this card */
  /* Browser thinks: "Hmm, this MIGHT affect other parts of the page" */
  /* Has to check EVERYTHING for possible impacts */
}

Example of why browser has to check everything:

  1. You change a card's height

  2. This could push other cards down

  3. Which could affect the page height

  4. Which could affect scrollbar

  5. Which could affect layout everywhere

With contain:

.card {
  contain: content;
  /* Browser: "Cool, anything that happens in here STAYS in here" */
  /* Only needs to recalculate THIS card, NOT the whole page */
}

Different types of containment:

  • contain: layout: Changes won't affect positioning outside

  • contain: paint: Visual changes won't leak outside

  • contain: size: Element's size doesn't depend on children (otherwise the element's size would for example grow when its children grow, causing layout to happen on the parent element)

  • contain: content: All of the above combined

Real example why it matters:

<div class="infinite-scroll">
  <div class="card">
    <!-- Card content -->
  </div>
  <!-- Hundreds more cards -->
</div>

Without contain:

  • Change one card

  • Browser: "Better check ALL cards for impacts"

With contain:

  • Change one card

  • Browser: "Only need to update this card!"

  • Much faster, especially with many cards on the page

It's like telling the browser: "This is a self-contained unit, you don't need to worry about anything outside it."

Deeper into CSS contain values

Let's look at "why" behind each of the 4 values I mentioned as it can be quite ambiguous.

Also, the reason I'm digging deeper here is because it fascinates me. When I first learned about it, it reminded me of re-renders in React.

It's not the same thing!! Don't get anything confused. I'm just sharing my thoughts.

Let me break down each contain value with practical examples of when and why you'd use them:

1. contain: size

.dropdown-menu {
  contain: size;
}

WHY: When an element's size shouldn't be affected by its children

  • Dropdown menus where content changes but container shouldn't resize (fixed height)

  • Fixed-size containers that shouldn't grow with content

2. contain: layout

.sidebar-widget {
  contain: layout;
}

WHY: When position changes inside shouldn't affect outside layout

  • Sidebar widgets that update independently

  • When adding/removing elements shouldn't trigger whole page reflow

3. contain: paint

.modal {
  contain: paint;
}

WHY: When visual changes shouldn't trigger repaints outside

  • Modals or overlays with animations

  • Elements with box-shadow or other visual effects that might leak

  • Complex animations that shouldn't force repaints of other areas

4. contain: content

.card {
  contain: content;
}

WHY: Complete isolation - combination of size, layout, and paint

  • Independent components that shouldn't affect anything else

  • Items in a long scrolling list

  • Elements that update frequently and independently

Usually, this is the most useful value if you need to reach for the contain property.

Example showing the differences:

/* Updates inside won't change container size */
.fixed-height-list {
  contain: size;
  height: 300px;
}

/* Items can move around inside without affecting outside */
/* Inside here however, we still need to do layout! */
.dynamic-feed {
  contain: layout;
}

/* Visual effects won't trigger repaints outside */
.animated-banner {
  contain: paint;
}

/* Completely independent component */
.profile-card {
  contain: content;
}

How do I know if I need it?

  1. You have many independent elements (like cards in a feed)

  2. These elements update frequently or independently

  3. You're seeing performance issues in DevTools (if not issues, you don't need it at all!!)

  4. You've measured and found layout/paint taking too long

Example where it helps:

<div class="feed">
  <div class="card"><!-- Updates frequently --></div>
  <div class="card"><!-- Updates frequently --></div>
  <!-- Hundreds more cards -->
</div>

All these cards shouldn't effect each other nor the entire page if one of them is updated.

Tradeoffs:

  • Creates new containing block, might break positioning or overflow behavior (see MDN example)

  • Might cause reflow if not used correctly

  • Additional memory for maintaining containment boundaries

How do I measure layout/paint performance?

Chrome DevTools Performance tab:

  • Open DevTools

  • Go to Performance tab

  • Click Record

  • Interact with your page

  • Stop recording

Look for:

  • Long purple bars (Layout)

  • Long green bars (Paint)

  • Red bars at top (Frame drops)

You'll see something like:

|----Layout (8ms)----|
    |--Paint (5ms)---|
        |--Composite--|

Warning signs:

  1. Layout/Paint taking > 16ms (means missed frames)

  2. Layout followed immediately by more Layout (layout thrashing)

  3. Many small Layout/Paint operations happening rapidly

Example of bad performance:

// This causes layout thrashing
cards.forEach((card) => {
  // Forces layout
  // Why?
  // We don't know if height has changed
  // Therefore, we're forced to recalculate layout to figure it out
  const height = card.offsetHeight;
  // Forces another layout
  // Why?
  // It's obvious. We're updating height.
  card.style.height = height + 100 + "px";
});

Better:

// Read all heights first
const heights = cards.map((card) => card.offsetHeight);
// Then do all writes
cards.forEach((card, i) => {
  card.style.height = heights[i] + 100 + "px";
});

If you see lots of layout/paint work in DevTools, then contain might help (if the element is actually isolated and it'd be fine).

But first try:

  1. Batching your DOM reads/writes

  2. Using transform/opacity for animations

  3. Reducing unnecessary updates

Remember: Profile first, optimize later!

Every optimization comes with a cost, not always a benefit.

Can the full rendering pipeline happen again?

Yes, it can.

Let's recap the steps:

  1. Style (calculate computed styles)

  2. Layout (calculate positions/sizes)

  3. Paint (draw elements)

  4. Composite (combine layers)

When layout is triggered:

  • We DO NOT need to start from Style if styles haven't changed

  • We DO start from Style if styles have changed

Example 1 -> Just Layout:

// Reading offsetHeight only needs layout recalc
console.log(element.offsetHeight);
// Starts at step 2 (Layout)

Example 2 -> Full Pipeline:

// Changing a style property
element.style.width = "200px";
// Must start at step 1 (Style)
// Because we changed styles!

That's why style changes are more expensive, they trigger the full pipeline starting from step 1. Layout-only changes (like reading offsetHeight) can start from step 2.

This is also why batching your reads and writes is important:

Bad way (layout thrashing):

cards.forEach((card) => {
  // 1. READ: forces layout
  const height = card.offsetHeight;
  // 2. WRITE: invalidates layout
  card.style.height = height + 100 + "px";
  // 3. Next iteration:
  //    READ forces another layout because previous WRITE invalidated it!
});

What happens:

  • Read → Layout calculation

  • Write → Layout invalidated

  • Read again → New Layout calculation (previous one was invalidated since we wrote to the DOM)

  • Write again → Layout invalidated

  • And so on... recalculating layout on every iteration!

The good way is to batch the reads and writes. The browser is smart (optimized) and will batch the layout calculations.

// 1. All READS first: only one layout calculation
// Only one time is needed here because we're not invalidating it in between
// ...by e.g. writing to the DOM
// Think of it like invalidating a cache
// If that mental model helps
const heights = cards.map((card) => card.offsetHeight);

// 2. All WRITES after: layout calculations batched
cards.forEach((card, i) => {
  card.style.height = heights[i] + 100 + "px";
});

What's the difference between isolation and containment?

Isolation isolate is only for z-index/stacking contexts. It doesn't do anything for performance.

CSS Contain is for performance optimization. It tells the browser that an element won't affect other elements. This way, the browser only needs to do work on that element if something changes. Which when you think about it, sounds really scary, because using it the wrong way can lead to some ugly bugs lol.

What's the difference between layout and reflow?

They're the same.

Reflow was the older term for layout. You still hear it a lot.

Layout is the modern term. Although, if I'm not mistaken, Firefox still uses the term "reflow".

Web Performance Deep Dive: From Browser Internals to Best Practices