Web Performance Deep Dive: From Browser Internals to Best Practices
My notes after spending 7 hours studying.

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
Load and Parse HTML:
Browser receives HTML text
Parses HTML into DOM tree
When it hits
<script>withoutdeferorasync: stops parsingWhen it hits
<link rel="stylesheet">: continues parsing but blocks rendering
Load and Parse CSS:
CSS loading starts as soon as
<link>is found (happens in parallel with HTML parsing)CSSOM construction is blocking:
Browser won't show ANY content until ALL CSS is loaded/parsed
Why? To avoid Flash of Unstyled Content (FOUC)
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."
JavaScript Execution:
Normal
<script>: blocks HTML parsing until downloaded & executed fklsdkfdl;skslasync: downloads in background, executes as soon as ready
defer: downloads in background, executes after HTML parsing
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)
Layout:
Calculate exact positions and sizes
Triggered by viewport size and content
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)
Composite:
Split content into layers
GPU combines layers for final display
Events:
DOMContentLoaded: DOM ready, but images/resources might not be.
DOMContentLoadeddoesn'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,DOMContentLoadedis triggered when stylesheets are ready. If you do NOT have any deferred scripts (usually you do if you're writing JS), thenDOMContentLoadedis triggered when the DOM is ready.load: Everything loaded including images/resources
Rendering Pipeline
Style: Calculate the styles that apply to the elements.
Layout: Generate the geometry and position for each element.
Paint: Fill out the pixels for each element.
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:
- Without layers (basic page):
// Everything is painted on one surface
// To move something, browser must repaint everything
element.style.left = "100px"; // Expensive! Repaint all
- 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
deferby default for your scripts.Save
asyncfor analytics/monitoring where early execution matters.Both avoid blocking HTML parsing during download.
asyncwill block during execution. That's why it's useful for monitoring scripts you want to run ASAP.defermaintains script order and waits for DOM.
2. Animation Performance
Use
transformandopacityfor animationsThese properties get their own layers and only trigger compositing (not the full rendering pipeline).
Avoid animating
top,left,width,height- they trigger full layoutUse CSS animations over JavaScript when possible - they run on compositor thread
3. Layer Management
Elements with transform/opacity automatically get layers
Use
will-changewisely 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: truefor 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-actionCSS instead of JS touch handlers when possibleEvent 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
requestAnimationFrameMoving 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:
You change a card's height
This could push other cards down
Which could affect the page height
Which could affect scrollbar
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 outsidecontain: paint: Visual changes won't leak outsidecontain: 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?
You have many independent elements (like cards in a feed)
These elements update frequently or independently
You're seeing performance issues in DevTools (if not issues, you don't need it at all!!)
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:
Layout/Paint taking > 16ms (means missed frames)
Layout followed immediately by more Layout (layout thrashing)
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:
Batching your DOM reads/writes
Using transform/opacity for animations
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:
Style (calculate computed styles)
Layout (calculate positions/sizes)
Paint (draw elements)
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".






