Web Performance
A complete executive deep-dive on Core Web Vitals, measurement, and optimization — written for a VP who needs the full picture, not buzzwords.
01. Introduction & Course Overview
The course this page summarizes (Todd Gardner, Frontend Masters) is structured around a single loop you will repeat forever inside your engineering organization:
- Measure — capture real user data, not lab opinions.
- Diagnose — find the metric that is hurting the worst user experience.
- Fix — apply the smallest change that moves that metric.
- Re-measure — confirm impact in production, not on a developer laptop.
Performance work fails in two predictable ways inside companies:
- "We don't have data" — teams optimize whatever feels slow on a fast MacBook on office Wi-Fi. That is not your user.
- "We optimized averages" — the average user is fine; the slow tail (p75/p95) is where churn lives. Google ranks on p75.
The Example Project — "Developer Stickers Online"
The course uses a fictional e-commerce site that demonstrates the three universally painful patterns:
- Slow-loading hero images — the largest above-the-fold visual takes 3+ seconds. (Hurts LCP.)
- Unpredictable layout changes — images and ads pop in and shove the page around. (Hurts CLS.)
- Delayed button clicks — the user clicks "Add to Cart" and nothing visibly happens for ~500 ms. (Hurts INP.)
These are the three categories Google rewards or penalizes you for. Memorize this triad: render speed, visual stability, responsiveness.
02. Importance of Web Performance
User Expectations of Performance
There are three independent reasons performance matters: user experience, SEO, and online advertising. They reinforce each other — a faster site ranks higher, gets more traffic, converts that traffic better, and pays less per ad click.
Research on user perception (Nielsen, Akamai, Google) consistently lands on roughly these numbers:
Often-cited statistics:
- 53% of mobile users abandon a site that takes more than 3 seconds to load (Google/SOASTA).
- A 1 second delay in page response reduces conversions by ~7% (Akamai).
- Amazon famously calculated that 100 ms of latency cost them ~1% in sales.
- Pinterest reduced perceived wait by 40% and saw a 15% jump in both SEO traffic and signups.
Performance Benefits, SEO & Advertising
SEO ranking
Since 2021, Google's Page Experience signal uses Core Web Vitals (LCP, CLS, INP) as a ranking factor. Specifically:
- Google measures the 75th percentile of real users in the field (via Chrome User Experience Report).
- A URL passes a metric only if 75% of visits meet the "Good" threshold.
- A page must pass all three to earn the "Good URL" classification.
- Failing pages do not get a hard penalty but lose to comparable competitors that do pass — especially on competitive queries.
Online advertising
- Google Ads Quality Score incorporates landing page experience, which leans on the same vitals. Better vitals = lower CPC for the same ad position.
- Slower landing pages have higher bounce-back-to-SERP rates, which Google interprets as a poor ad and penalizes future auctions.
- For programmatic display, slow pages reduce viewable impressions — ads that never render don't earn revenue. Publishers commonly see 5–10% revenue uplift just from fixing CLS-driven ad layout instability.
Waterfall Charts
A waterfall chart is the foundational visualization for understanding how a page loads. Each row is one network request; the row's position shows when it started and how long it took. Stacked vertically, the rows reveal sequential dependencies (what was blocking what) and parallelism (what loaded simultaneously).
A waterfall makes three things obvious:
- What is on the critical path — the longest sequence of dependencies that must complete before the user sees content.
- Where the gaps are — empty space between requests means the browser was idle, often waiting for the parser to discover the next resource.
- What is wasting bandwidth — long bars on third-party scripts or oversized images that delay everything else.
Measuring DOMContentLoaded & Load Events
Two original browser events were the first generation of "page is ready" signals:
// Legacy way to measure
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM ready at', performance.now());
});
window.addEventListener('load', () => {
console.log('Page fully loaded at', performance.now());
});
The Problem with Legacy Metrics
These metrics were honest in 2005 when pages were static. In 2010+ the web moved to client-side rendering (React, Vue, Angular). Now:
DOMContentLoadedfires when the HTML — basically<div id="root"></div>— finishes parsing. The user sees nothing.loadfires after JS bundles download, but well before React has actually mounted and rendered the UI.- Single-page apps perform "navigation" without firing either event again, so all subsequent screens are invisible to these metrics.
load/DOMContentLoaded is measuring the wrong thing. Modern decisions must use Core Web Vitals, which measure what the user actually experiences, not what the browser internally finished.
03. Core Web Vitals & Other Performance Metrics
Core Web Vitals (CWV) are Google's three user-experience metrics that gate Search ranking. Each metric is bucketed into Good / Needs Improvement / Poor based on the p75 of real users.
| Metric | What it captures | Good | Needs work | Poor |
|---|---|---|---|---|
| LCP | Render speed of biggest visual | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| CLS | Visual stability | ≤ 0.1 | ≤ 0.25 | > 0.25 |
| INP | Interaction responsiveness | ≤ 200ms | ≤ 500ms | > 500ms |
Largest Contentful Paint (LCP)
What it measures: the time from navigation start to when the largest content element visible in the viewport is rendered. It is a proxy for "when did the user perceive the main content?"
What counts as the "largest element"?
<img>and<image>inside SVG<video>poster images- Elements with a
background-imageloaded viaurl()(gradients do not count) - Block-level text nodes
Rules & gotchas
- Only the visible area at first paint is considered. An element bigger than the viewport is capped at the viewport size.
- If the largest element changes as the page loads, the latest valid candidate wins — until the user interacts (scroll, click, keypress). The first interaction freezes the LCP value.
- CSS-applied opacity 0 or transforms that move it out of view exclude an element.
- Background images loaded with
background: url(...)count; CSS gradients do not.
The four phases of LCP
To diagnose LCP you must decompose it. Google divides LCP into four sub-phases — fixing the biggest phase first gives the biggest win:
- TTFB (Time to First Byte) — server response time.
- Resource Load Delay — gap between TTFB and when the LCP resource starts downloading.
- Resource Load Duration — how long the LCP image/file took to download.
- Element Render Delay — gap between the resource finishing and the browser actually painting it.
// Measuring LCP with the PerformanceObserver API
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP candidate:', entry.startTime, entry.element);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
Cumulative Layout Shift (CLS)
What it measures: how much visible content unexpectedly moves around during the lifetime of the page. CLS rewards visual stability. A shift counts only if the element was previously visible.
How CLS is calculated
For every layout-shift event, Google computes:
shift_score = impact_fraction × distance_fraction
- Impact fraction — combined visible area of elements that moved, divided by viewport area.
- Distance fraction — largest distance any moved element traveled, divided by the larger viewport dimension (height for vertical shifts, width for horizontal).
CLS sums shift scores within a 5-second session window, then takes the worst window on the page. This stops long-lived pages from accumulating noise.
Common causes
- Images without width/height attributes — layout collapses, then grows when the image loads.
- Late-injected ads, embeds, or iframes pushing content down.
- Dynamic banner notifications inserted at top of the document.
- Web fonts swapping after FOUT (Flash of Unstyled Text), changing line metrics.
- Animations using
top/left(which reflow) instead oftransform(which only composites).
CLS Q&A
- Why isn't the page header included in impact fraction? — The header doesn't move; only elements that shifted count. Their pre-shift visible area is added to the impact region.
- Does scrolling affect CLS? — No. Scroll-triggered movement is expected and excluded. CLS is about unexpected shifts only.
- Does server-side rendering remove layout shifts? — It eliminates many (HTML arrives with correct dimensions) but not all. Late-loading images, fonts, and client-side hydration can still shift the page.
- Do shifts within 500 ms of a user input count? — No. The browser flags such shifts as
hadRecentInputand excludes them, assuming the user caused them.
Flame Charts
A flame chart visualizes JavaScript execution over time. The x-axis is time; the y-axis is the call stack. Each block is a function; nested blocks are functions called by their parent. Width = time the function took.
In Chrome DevTools the colors map to event types:
- Yellow — Scripting (JS execution)
- Purple — Rendering / Layout
- Cyan/Blue — Painting / Compositing
- Green — Idle
- Red — Long tasks (>50 ms) — the enemy of INP
Why VPs should care: long flat yellow bars are why your "Add to Cart" button feels frozen. They are usually traceable to a single third-party tag, an oversized React render, or a heavy synchronous computation.
Interaction to Next Paint (INP)
What it measures: the latency between a user interaction (click, tap, keypress) and the very next visual update. INP reports the worst-case interaction the user experienced on the page (with some allowance for outliers above 50 interactions).
The anatomy of an interaction
- Input delay — the gap between the user's tap and when your handler can run. Caused by the main thread being busy with other JS (long tasks).
- Processing time — how long your event handler(s) take to run.
- Presentation delay — time the browser takes to recalculate styles, layout, paint, and composite the next frame.
INP = the maximum of these three for the slowest interaction on the page.
What counts as an interaction
- Mouse clicks
- Touchscreen taps
- Key presses (typing)
Pure scroll and hover do NOT count.
Device capability matters enormously. A budget Android phone has 1/10th the single-thread performance of an iPhone Pro. Your INP at p75 on real users is dominated by mid-tier mobile, not your laptop.
First Input Delay (FID) — Legacy
FID was the original interactivity metric and measured only the input delay of the first interaction on a page. It was retired in March 2024 in favor of INP because:
- FID only measured delay, not handler runtime or paint — the user could still wait forever after handler started.
- It captured a single interaction; INP captures the worst across the whole session.
- First interactions are usually cheap; subsequent ones (filter, sort, add-to-cart) are where users actually suffer.
Time to First Byte (TTFB)
TTFB is the duration from the user clicking a link to the browser receiving the first byte of the HTML response. It is not a Core Web Vital but it is the floor for every other metric — you cannot start rendering before bytes arrive.
What's inside TTFB
- Redirects — each 301/302 adds a full round trip.
- DNS lookup — resolving the hostname.
- TCP connection — 3-way handshake.
- TLS negotiation — cipher suite, certificate exchange.
- Request — browser sends headers and any body.
- Server processing — your application code, database queries, cache lookups.
- First byte received — the response begins streaming back.
First Contentful Paint (FCP)
FCP is the moment any text, image, SVG, or non-white canvas appears in the browser window. It is the first visual signal to the user that something is happening.
FCP is bounded by:
- TTFB — you cannot paint before HTML arrives.
- Render-blocking resources — CSS in <head> and synchronous scripts must complete first.
- Font display strategy —
font-display: blockcan delay FCP by up to 3s while waiting for custom fonts.
04. Capturing Performance Metrics
Performance API
The browser exposes a built-in performance object that lets you capture timing data programmatically. Three key surfaces:
// Custom business metric: time to "Buy Now" button visible
performance.mark('route-changed');
// ... React renders, data fetches, etc.
performance.mark('buy-button-visible');
performance.measure('route-to-buy', 'route-changed', 'buy-button-visible');
const [entry] = performance.getEntriesByName('route-to-buy');
console.log('Took', entry.duration, 'ms');
PerformanceObserver
Calling performance.getEntries() from your own code has an "observer effect" — it runs on the main thread and can itself cause jank. PerformanceObserver solves this by letting you subscribe to entry types and receive them asynchronously when the browser is idle.
// Observe layout shifts
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
sendBeacon('cls', entry.value);
}
}
});
obs.observe({
type: 'layout-shift',
buffered: true // also deliver entries that already happened
});
Important: pass buffered: true so that you don't miss entries that fired before your observer attached — critical for LCP and CLS, which often start accumulating before your monitoring code loads.
Browser Support for Performance Metrics
Browser support is uneven and matters for what you can measure for whom:
| Metric | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| DOMContentLoaded / load | ✓ | ✓ | ✓ |
| TTFB | ✓ | ✓ | ✓ |
| FCP | ✓ | ✓ | ✓ (recent) |
| LCP | ✓ | partial | × |
| CLS | ✓ | partial | × |
| INP | ✓ | × | × |
Because Core Web Vitals are Chromium-only for measurement purposes, Google's ranking signal is based on Chrome data — which is fine since Chromium represents ~70% of global browser share. But your Safari users won't show up in CrUX data; you need a RUM tool with its own collection layer to see them.
05. Testing & Tools
Testing Performance — Three Data Types
| Type | What it is | Strength | Weakness |
|---|---|---|---|
| Lab | Single, controlled test (Lighthouse on your laptop) | Reproducible, debuggable | One device, one network, one user — not reality |
| Synthetic | Lab tests run on a schedule from cloud locations | Trend over time, alerts on regressions | Still not real users |
| Field (RUM) | Real users, real networks, real devices | Ground truth — this is what Google ranks on | Noisy, requires statistical analysis |
Statistics — Why Averages Lie
The mean (average) of a long-tail distribution is meaningless for performance:
- If 90% of users see a 1s LCP and 10% see a 10s LCP, the average is 1.9s — which sounds fine but the worst 10% are abandoning.
- Percentiles tell the real story:
- p50 (median) — typical user.
- p75 — Google's ranking threshold. The slowest quarter.
- p95 — the worst-served users; usually mobile + bad network + old device.
- p99 — tail extremes; often instrumentation noise but sometimes a real cohort.
Google Lighthouse
Lighthouse is the built-in Chrome DevTools auditor (also available as CLI, Node module, and PageSpeed Insights). It runs your site under simulated mobile + slow 4G and produces:
- A 0–100 Performance score (weighted combination of FCP, LCP, TBT, CLS, SI).
- Per-metric breakdown with values.
- Actionable Opportunities (each estimated to save X seconds).
- Diagnostics — non-actionable observations like long main-thread tasks.
You can configure the run:
- Device: mobile (default) vs desktop.
- Network throttling: simulated slow 4G is the default; reflects p75 of mobile users globally.
- CPU throttling: 4x slowdown by default, approximating a mid-tier Android.
Analyzing Results in the Performance Tab
Chrome's Performance panel records a full trace of everything the browser did during a recording session. After Lighthouse generates a report, click into the trace to see:
- Network row — full waterfall with request priorities.
- Frames row — screenshots at every painted frame.
- Main row — flame chart of JS execution on the main thread.
- Layout shifts — highlighted with red bars; click to see which element moved.
- LCP marker — vertical line showing exactly when the largest paint occurred.
The Memory tab exists in the same panel but is not commonly used for production diagnosis — memory leaks rarely show up cleanly in short recordings. Use specialized RUM-level memory tracking instead.
Web Vitals Chrome Extension
A free Google extension that displays live LCP, CLS, and INP for the page you are currently on. Key features:
- Real-time on-page overlay or in the console.
- Optional "show field data from real Chrome users" toggle — pulls CrUX data for the current URL.
- Per-interaction INP breakdown showing input delay, processing, presentation.
This is the first tool every developer in your org should install.
Chrome User Experience Report (CrUX)
CrUX is Google's public dataset of real Chrome user metrics. It is the dataset Google uses to determine Page Experience for ranking. You can access it via:
- PageSpeed Insights — gives both Lighthouse (lab) and CrUX (field) data for any URL.
- CrUX dashboard on Looker Studio.
- CrUX BigQuery dataset — query monthly aggregates for any origin.
- CrUX API — programmatic access for monitoring.
Limits to know:
- Only Chrome users with usage statistics enabled are included.
- Only public, well-trafficked URLs have data; long-tail URLs return "insufficient data".
- Aggregated to the 28-day p75. Changes you ship today won't reflect for 4 weeks.
WebPageTest.org
The most flexible synthetic testing tool, originally built by Pat Meenan (now Catchpoint). Compared to PageSpeed Insights it offers:
- Test from real machines in dozens of cities worldwide.
- Choose specific browsers, devices, and network profiles (e.g., "Moto G4 on 3G in Mumbai").
- Run multiple sequential views (first visit vs returning user with cache).
- Scripted multi-step tests (login → navigate → checkout).
- Full filmstrip showing what the user saw at every 100 ms.
- Carbon-cost estimation for sustainability reports.
Real User Monitoring (RUM)
RUM is a small JavaScript snippet you put on every page that captures performance data from your actual users (not just Chrome, not just opt-ins). The snippet uses PerformanceObserver internally and beacons data to a backend you can query and chart.
RUM categories and example vendors:
- Enterprise APM — Akamai mPulse, Datadog RUM, New Relic Browser, Dynatrace.
- Developer-focused — Request Metrics, SpeedCurve, Calibre.
- DIY — web-vitals.js + your own ingest endpoint into ClickHouse / BigQuery.
Request Metrics — Example RUM Tool
The course author (Todd Gardner) built Request Metrics, and uses it to illustrate what a good RUM dashboard provides:
- Real-time p75/p95 for every Core Web Vital, broken down by URL pattern.
- Filtering by geography, network type, device, browser, referrer.
- Per-page "why is this slow?" drilldowns — which resource was the LCP element, which script caused the slow INP, etc.
- Comparison to CrUX so you know if you're above or below the global benchmark for similar sites.
06. Setting Performance Goals
How Fast Should Your Site Be?
"Fast" is subjective. The same 3-second wait feels different depending on context:
- Task importance — users wait longer for a tax filing than for a meme.
- Predictability — a progress bar makes 5 seconds tolerable; a frozen spinner makes 2 feel broken.
- Boredom & anxiety — users with nothing to do during the wait perceive it as longer.
- Uncertainty — "is this working?" is the worst user feeling. Always show something happening within 1 second of any interaction.
Determining Performance Goals
Three inputs drive your goal:
Concrete process:
- Test your top 5 competitors in PageSpeed Insights. Note their CrUX p75 values.
- For each metric, your goal is the better of: (a) Google's "Good" threshold, (b) 20% faster than the best competitor.
- Convert goals into a quarterly OKR: "Reduce p75 LCP on /product pages from 3.4s to 2.2s by Q3."
Understanding Your Users
Before optimizing, know who you're optimizing for. From global stats:
- ~60% of web traffic is mobile.
- The median Android device sold globally has roughly 1/4 the single-thread CPU of an iPhone Pro.
- Median worldwide mobile network is closer to 4G (~10 Mbps down, 100ms RTT) than 5G.
- Most-used screen widths are 360–414 (mobile) and 1366×768 (laptop).
Then layer in your users via analytics:
- What is the geographic split? (Drives CDN strategy.)
- What is the device split? (Drives JS budget.)
- What is the network type split? (Drives image strategy.)
- What is the returning-visitor share? (Drives caching strategy.)
07. Improving Time to First Byte
The Performance Improvement Loop
The optimization workflow is deterministic:
- Measure in production with RUM.
- Pick the worst metric at p75. Don't try to fix everything.
- Pick the easiest fix for that metric. Cheap wins build momentum.
- Find ways to do fewer things between the two events the metric measures.
- Ship, measure again, repeat.
Baseline TTFB
Measure TTFB in Chrome DevTools → Network → click the HTML document request → Timing tab. You'll see DNS, Initial connection, SSL, Waiting (TTFB), Content download.
The "Waiting" segment is your server's response time. If it's 500 ms+, the win is server-side. If "Initial connection" + "SSL" are huge, the win is network/CDN.
Google targets:
Enabling Gzip & Brotli Compression
Text-based responses (HTML, CSS, JS, JSON, SVG) compress 70–90%. Two standard algorithms:
| Algorithm | Compression ratio | CPU cost | Notes |
|---|---|---|---|
| gzip | ~70% | Low | Universal support since 1990s. Default fallback. |
| Brotli | ~80% | Higher (but pre-compressible) | Better ratio. Supported by all modern browsers. Use level 11 for static assets at build time, level 4 for dynamic. |
# Nginx example
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
brotli on;
brotli_types text/css application/javascript application/json image/svg+xml;
brotli_static on; # serve pre-compressed .br files when present
Efficient Protocols: HTTP/1.1 vs HTTP/2 vs HTTP/3
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Year | 1997 | 2015 | 2022 |
| Transport | TCP | TCP | QUIC (UDP) |
| Multiplexing | One req per connection | Many streams per connection | Many streams, no head-of-line blocking |
| Header compression | None | HPACK | QPACK |
| Setup RTT | TCP+TLS = ~2-3 RTT | TCP+TLS = ~2-3 RTT | QUIC = 1 RTT (0 RTT on resume) |
| Recovery from packet loss | Blocks whole connection | Blocks all streams (TCP HOL) | Only the affected stream |
HTTP/2 made the big win: instead of opening 6 separate TCP connections to your domain (browser limit), the browser opens one and multiplexes all requests over it. This eliminates connection-setup overhead and lets the server prioritize critical resources.
HTTP/3 goes further by replacing TCP with QUIC (over UDP). The TCP "head-of-line blocking" problem — one dropped packet pauses all streams on the connection — is solved because QUIC streams are independent. HTTP/3 also reuses prior TLS session keys to achieve 0-RTT reconnection.
Using HTTP/2 & HTTP/3
You don't need to change application code. Enable at the server / CDN:
- Cloudflare, Fastly, CloudFront, Akamai — HTTP/2 on by default; HTTP/3 a single toggle.
- Nginx —
listen 443 ssl http2;and (in 1.25+)listen 443 quic reuseport;. - Caddy — HTTP/3 enabled by default.
The browser will first connect over HTTPS+HTTP/2, then discover HTTP/3 via the Alt-Svc response header, and use UDP for subsequent connections.
Host Capacity & Proximity
Two structural levers on TTFB beyond software:
1. Right-size the host
If your server is CPU- or memory-pinned, requests queue and TTFB climbs. Provision enough capacity to keep CPU below 60% at peak, then scale horizontally with a load balancer.
2. Reduce distance with a CDN
The speed of light is non-negotiable. A request from Sydney to Virginia is ~200 ms round-trip just from physics. A CDN places edge servers in dozens to hundreds of cities, terminating TLS and (often) serving cached HTML close to the user.
- Static-only CDN — caches CSS, JS, images at the edge. Good for asset performance, less for HTML.
- Full-HTML CDN — caches dynamic pages with short TTLs or stale-while-revalidate. Massive TTFB win for content-heavy sites.
- Edge compute — runs your code at the edge (Cloudflare Workers, Vercel Edge Functions, Fastly Compute@Edge). Best of both worlds for personalized pages.
08. Improving First Contentful Paint
Baseline FCP & the Three Tactics
FCP improvement always uses some combination of three tactics:
- Remove sequence chains — flatten dependency trees so resources start earlier.
- Preload critical resources — tell the browser about important assets before the parser would discover them.
- Lazy load non-critical resources — push everything that isn't needed for first paint to later.
Removing Sequence Chains (Collapsing Dependencies)
A "sequence chain" is a request that can only start after another request finishes. Classic example: HTML → CSS → font file. The font cannot be requested until CSS is parsed, which cannot start until CSS is downloaded, which cannot start until HTML is parsed enough to find it.
<!-- BAD: sequence chain -->
<link rel="stylesheet" href="theme.css">
<!-- inside theme.css: @import url('fonts.css'); -->
<!-- inside fonts.css: @font-face { src: url('Inter.woff2'); } -->
<!-- GOOD: bundled and preloaded -->
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/bundle.css"> <!-- font-face inlined -->
A bundler (Webpack, Vite, esbuild) collapses these by inlining or co-locating dependencies so the browser sees one request instead of three sequential ones.
Preloading Resources
Three relationships to a future request you can hint to the browser:
| Hint | What it does | When to use |
|---|---|---|
dns-prefetch | Resolves the DNS for a hostname | Third-party domain you'll need soon |
preconnect | DNS + TCP + TLS setup | Critical third party (fonts, analytics, CDN) |
preload | Actually fetches the resource immediately at high priority | Known critical resource the parser would discover late (LCP image, hero font) |
prefetch | Fetches at low priority for likely next navigation | "User will probably click this link" predictions |
<!-- Google Fonts pattern -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Inter&display=swap">
Lazy Loading Scripts
By default, a <script src="..."> in the HTML blocks parsing until it downloads and runs. There are two attributes to fix this:
| Attribute | Download | Execution | Order preserved? |
|---|---|---|---|
| (none) | Blocks parser | Immediately, blocking | Yes |
async | Parallel, non-blocking | As soon as downloaded, blocking | No |
defer | Parallel, non-blocking | After HTML parsed, before DOMContentLoaded | Yes |
type="module" | Parallel | Deferred by default | Yes |
<!-- Analytics, A/B testing, anything not needed for first paint -->
<script src="/analytics.js" defer></script>
<script src="/abtest.js" async></script>
<script> tags to defer. Use async only when execution order doesn't matter and the script is fully self-contained (typical of analytics).
09. Improving Largest Contentful Paint
Baseline LCP & Its Components
Recall the four sub-phases of LCP:
- TTFB — covered above.
- Resource load delay — reduce by preloading the LCP element.
- Resource load duration — reduce by smaller, better-formatted images.
- Element render delay — reduce by avoiding render-blocking CSS/JS that holds back paint.
Lazy Loading Non-LCP Images
Every image below the fold competes for bandwidth with your LCP element. Mark them as lazy so the browser defers their download until they're near the viewport:
<img src="/sticker-1.png" alt="Sticker" loading="lazy" width="240" height="240">
Critical caveat: never put loading="lazy" on the LCP image itself — it will be deprioritized and your LCP will get worse, not better.
Eager Loading the LCP Image
Two ways to tell the browser an image is the priority:
<!-- 1. fetchpriority attribute -->
<img src="/hero.jpg" alt="Hero" fetchpriority="high" width="1200" height="600">
<!-- 2. preload in the head -->
<link rel="preload" as="image" href="/hero.jpg" fetchpriority="high">
The difference: fetchpriority works once the parser discovers the image; preload works before the parser gets there. For above-the-fold heroes, use both for belt-and-braces.
Image Formats
HTTP compression does NOT help images — they are already compressed. The win comes from choosing the right format:
| Format | Use | Avg. file size | Transparency | Animation |
|---|---|---|---|---|
| JPEG | Photos | 100% (baseline) | No | No |
| PNG | Diagrams, logos | ~150% of JPEG for photos | Yes | No |
| WebP | General-purpose modern | ~70% of JPEG | Yes | Yes |
| AVIF | Modern, best compression | ~50% of JPEG | Yes | Yes |
| SVG | Vector graphics (icons, logos) | Tiny if simple | Yes | Yes |
Responsive Images
Don't ship a 4000-pixel desktop image to a 400-pixel phone. Use srcset + sizes or the <picture> element to serve the right size:
<!-- srcset -->
<img
src="/hero-1200.jpg"
srcset="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1200.jpg 1200w"
sizes="(max-width: 600px) 100vw, 50vw"
alt="Hero"
width="1200" height="600">
Optimizing Images with <picture>
The <picture> element gives full control over format + size negotiation with fallback:
<picture>
<source type="image/avif" srcset="/hero-400.avif 400w, /hero-800.avif 800w" sizes="100vw">
<source type="image/webp" srcset="/hero-400.webp 400w, /hero-800.webp 800w" sizes="100vw">
<img src="/hero-800.jpg" alt="Hero" width="1200" height="600">
</picture>
The browser picks the first <source> with a format it supports, falling back to the <img> for legacy browsers. The width/height on the <img> reserve layout space (good for CLS, see below).
Workflow:
- Source files in highest quality (JPEG/PNG).
- Build script generates multiple resolutions of each, in WebP and AVIF.
- Use a CDN that does this on-the-fly (Cloudinary, Imgix, Cloudflare Images, Next/Image).
Caching
Caching turns repeat visits free for both the server (no compute) and the user (no download). Two layers:
Server caching
Cache rendered HTML, API responses, database queries at the server / CDN. Redis, Memcached, or built into the CDN edge.
Browser response caching
HTTP headers tell the browser how long it can re-use a response without asking the server. Two relevant headers:
Cache-Control: public, max-age=31536000, immutable — cache for a year, never re-check.Enabling Caching Headers
Static, fingerprinted assets
Cache-Control: public, max-age=31536000, immutable
This is for files like /app.abc123.js where the hash changes if content changes. Cache forever; bust by URL change.
HTML
Cache-Control: public, max-age=0, must-revalidate
ETag: "abc123"
HTML changes often; revalidate every visit, but allow 304 responses to save bandwidth.
API responses
Cache-Control: private, max-age=60, stale-while-revalidate=600
Cache for the current user for 60 seconds; serve stale for up to 10 minutes while fetching a fresh version in the background.
Cache-Control headers at all or have no-store on assets that never change. Enabling proper headers can cut returning-user LCP by 50%+ with zero code changes — it's purely server config.
10. Improving CLS & INP
Layout Size Hints (CLS)
Almost all CLS comes from elements that arrive after the initial render and push other content around. The fix is to reserve their space upfront so the browser doesn't have to reflow when they appear.
For images and videos
Always set width and height attributes. The browser uses the ratio to reserve a properly proportioned box even before the image downloads.
<!-- Browser reserves the correct aspect ratio -->
<img src="/product.jpg" alt="Product" width="800" height="600">
For aspect-ratio CSS
.hero {
width: 100%;
aspect-ratio: 16 / 9;
}
For ads and embeds
Reserve a fixed slot. If an ad doesn't fill, leave the empty box rather than collapse:
.ad-slot {
width: 300px;
min-height: 250px;
}
For dynamically-injected content (banners, cookie bars, alerts)
Insert at the bottom of the viewport (overlay), not at the top of the document. Use position: fixed so it doesn't displace existing flow.
For web fonts
Use font-display: swap with carefully chosen size-adjust and ascent-override on the fallback font so the swap doesn't change line metrics:
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
}
Improving Interaction to Next Paint
INP is about how quickly the browser can paint after the user does something. To improve it, you must:
- Reduce input delay — keep the main thread free at the moment of interaction.
- Reduce handler runtime — do less work synchronously in your event handlers.
- Reduce presentation delay — minimize the work the browser must do after your handler returns.
Two browser APIs are essential:
fn on the next macrotask. Use to defer non-critical work until after paint.fn just before the next paint. Use to schedule visual updates so they happen on the right side of a frame.Yielding the Main Thread — A Worked Example
Consider an "Add to Cart" handler that does five things synchronously:
// BAD: blocks the main thread for ~600ms
addToCartBtn.addEventListener('click', async () => {
const response = await fetch('/api/cart/add', { method: 'POST', body: ... });
updateCartIcon(); // re-render badge
trackAnalyticsEvent(); // expensive third-party call
showSuccessAnimation(); // large DOM mutation
refreshRecommendations(); // fetch + render carousel
disableButton(); // visual feedback
});
The user clicks but sees nothing change until all five complete. Rewrite to give immediate feedback, then yield for the rest:
// GOOD: instant visual feedback, deferred non-essentials
addToCartBtn.addEventListener('click', () => {
// 1. Synchronous visual feedback — happens on the next paint
requestAnimationFrame(() => {
addToCartBtn.disabled = true;
addToCartBtn.textContent = 'Adding...';
});
// 2. Critical async work after paint
setTimeout(async () => {
await fetch('/api/cart/add', { method: 'POST', body: ... });
updateCartIcon();
showSuccessAnimation();
}, 0);
// 3. Non-essential work, far later
setTimeout(() => {
trackAnalyticsEvent();
refreshRecommendations();
}, 200);
});
The user perceives the button change instantly — INP drops from ~600 ms to ~30 ms even though total work is identical.
Other INP tactics
- Break up long tasks — chop loops with
await scheduler.yield()orsetTimeoutevery few iterations. - Move heavy work off-thread — Web Workers for parsing, sorting, image manipulation.
- Debounce/throttle input — don't fire on every keystroke; wait until typing pauses.
- Audit third-party tags — tag managers, A/B testing, chatbots. Each one can add hundreds of ms.
- Reduce DOM size — a 5000-node DOM is slow to update; aim under 1500 visible nodes per page.
- Avoid layout thrashing — batch DOM reads, then batch DOM writes; never alternate.
11. Wrapping Up — Executive Summary
If you take away nothing else, take this:
- Measure real users, not your laptop. Field data > lab data.
- Track p75, not averages. Google ranks on p75; tail users are your churn.
- Focus on the worst metric on the worst page. Don't optimize what's already good.
- Ship the easiest fix first. Compounded small wins beat one giant rewrite.
- Validate in production. Lab improvements don't always translate to field.
- Treat performance as a budget, not a project. Set per-page budgets in CI; block PRs that exceed them.
VP Cheatsheet — The 12 Numbers to Memorize
| Metric | Good | Needs work | Poor | What it captures |
|---|---|---|---|---|
| LCP | ≤ 2.5s | ≤ 4.0s | > 4.0s | Main content visible |
| CLS | ≤ 0.10 | ≤ 0.25 | > 0.25 | Layout stability |
| INP | ≤ 200ms | ≤ 500ms | > 500ms | Interaction responsiveness |
| TTFB | ≤ 800ms | ≤ 1.8s | > 1.8s | Server speed |
| FCP | ≤ 1.8s | ≤ 3.0s | > 3.0s | First visual signal |
Org-level checklist
- RUM tool deployed across all production properties — Yes/No
- p75 of all three CWVs published in a weekly exec dashboard
- Synthetic tests in CI; PRs that regress performance budgets are blocked
- CDN with HTTP/2 + HTTP/3 + Brotli enabled
- Images served as WebP/AVIF with responsive
srcset - All
<img>tags havewidthandheight - Hero image of every key landing page is preloaded with
fetchpriority="high" - Third-party scripts use
deferorasyncby default - Cache-Control headers set on every static asset (1 year + immutable)
- Quarterly performance OKR tied to revenue / conversion metric
Source course: Web Performance Fundamentals by Todd Gardner (Frontend Masters). This page is an executive-level expansion with additional industry context.