Skip to main content

Web Performance

A fast website is not optional - it affects user experience, conversion rates, SEO rankings, and accessibility. This guide covers the metrics, techniques, and strategies that matter most.

Core Web Vitals​

Google's Core Web Vitals are the three metrics that define a good user experience:

LCP - Largest Contentful Paint​

Measures loading performance: how long until the largest visible element (hero image, heading block, video poster) is rendered.

RatingThreshold
Good≤ 2.5 seconds
Needs improvement2.5 - 4.0 seconds
Poor> 4.0 seconds

Common LCP elements: hero images, <h1> text blocks, video poster frames, large SVGs.

What hurts LCP:

  • Slow server response (high TTFB)
  • Render-blocking CSS or JavaScript
  • Large, unoptimized images
  • Lazy-loading the LCP element (do not lazy-load the hero)

What helps LCP:

  • Preload the LCP image: <link rel="preload" as="image" href="hero.webp">
  • Use a CDN to reduce server latency
  • Inline critical CSS
  • Set fetchpriority="high" on the LCP image

INP - Interaction to Next Paint​

Measures interactivity: how long between a user action (click, tap, keypress) and the next visual update.

RatingThreshold
Good≤ 200 ms
Needs improvement200 - 500 ms
Poor> 500 ms

What hurts INP:

  • Long JavaScript tasks (> 50ms) blocking the main thread
  • Heavy event handlers
  • Layout thrashing (reading then writing DOM in a loop)
  • Third-party scripts competing for the main thread

What helps INP:

  • Break long tasks into smaller chunks with scheduler.yield(), requestAnimationFrame, or short setTimeout() breaks; reserve requestIdleCallback() for non-urgent background work
  • Debounce input handlers
  • Use requestAnimationFrame for visual updates
  • Move heavy work to Web Workers

CLS - Cumulative Layout Shift​

Measures visual stability: how much visible content moves unexpectedly during loading.

RatingThreshold
Good≤ 0.1
Needs improvement0.1 - 0.25
Poor> 0.25

What causes CLS:

  • Images without dimensions (width/height attributes or CSS aspect-ratio)
  • Ads, embeds, or iframes that resize after load
  • Web fonts causing text reflow (FOIT/FOUT)
  • Dynamically injected content above the fold

What prevents CLS:

  • Always set width and height on images and videos
  • Use aspect-ratio in CSS for responsive containers
  • Reserve space for ads and embeds
  • Pick a font-display strategy that balances brand needs and layout stability (swap, fallback, or optional)

Measuring performance​

Lab tools (synthetic)​

ToolWhat it does
Lighthouse (Chrome DevTools)Full audit: performance, accessibility, SEO, best practices
PageSpeed InsightsLighthouse + field data from Chrome User Experience Report
WebPageTestFilmstrip view, waterfall chart, multi-location testing
Chrome DevTools Performance panelFlame chart for main thread activity, long tasks, layout shifts

Field data (real users)​

SourceWhat it provides
CrUX (Chrome User Experience Report)Real-user CWV data, available via PageSpeed Insights and BigQuery
web-vitals.jsJavaScript library to measure CWV in production and send to analytics
RUM (Real User Monitoring)Services like Datadog, New Relic, Vercel Analytics

Lab vs field:

LabField
ConsistencyReproducible, controlledVaries by device, network, location
DebuggingEasy - you control the environmentHard - aggregate data only
Reflects real usersNo - tests one device/networkYes - actual user experiences
Use forDevelopment, debugging, CI gatesMonitoring, trending, SEO impact

Always look at both. Lab catches regressions during development; field confirms the impact on real users.

Images​

Images are typically the heaviest assets on a page. Optimizing them has the biggest impact on LCP and total page weight.

Modern formats​

FormatUse forSavings vs JPEG
WebPPhotos, illustrations~25-35% smaller
AVIFPhotos (best compression)~40-50% smaller
SVGIcons, logos, illustrationsScalable, tiny for simple shapes
PNGScreenshots, images with transparencyLossless but large

The <picture> element​

Serve the best format the browser supports:

<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="Hero image"
width="1200" height="600"
loading="lazy"
decoding="async">
</picture>

Responsive images​

Serve different sizes for different viewports:

<img src="photo-800.webp"
srcset="photo-400.webp 400w,
photo-800.webp 800w,
photo-1200.webp 1200w"
sizes="(max-width: 600px) 400px,
(max-width: 1024px) 800px,
1200px"
alt="Product photo"
width="1200" height="800"
loading="lazy"
decoding="async">

Image best practices​

  • Always set dimensions - width and height attributes prevent CLS
  • Lazy-load below-the-fold images - loading="lazy" on <img>
  • Do NOT lazy-load the LCP image - it must load immediately
  • Use fetchpriority="high" on the LCP image
  • Use decoding="async" on non-critical images
  • Compress images - aim for 80-85% quality, rarely noticeable below that
  • Use an image CDN (Cloudinary, imgix, Vercel Image Optimization) for automatic resizing and format conversion

Fonts​

Web fonts can cause both layout shifts (CLS) and render delays (LCP).

font-display values​

ValueBehaviorCLS risk
swapShow fallback immediately, swap when loadedMedium - text can reflow on swap
optionalShow fallback, use web font only if it loads immediatelyLowest - many first visits stay on fallback
fallbackBrief invisible period (100ms), then fallback, then swapLow
blockInvisible text for up to 3 secondsNone, but FOIT (flash of invisible text)

Recommendation: Default to swap or fallback for readable text that should reliably use the web font. Use optional when zero layout shift matters more than guaranteed first-visit brand-font rendering.

Font loading strategies​

<!-- Preload the most critical font -->
<link rel="preload" as="font" type="font/woff2"
href="/fonts/inter-v13-latin-400.woff2" crossorigin>
  • Self-host fonts when practical - it often improves privacy and lets you skip an extra stylesheet request
  • Use WOFF2 - best compression, supported by all modern browsers
  • Subset fonts - include only the character sets you need (latin, latin-ext)
  • Limit font weights - every weight is a separate file; use 2-3 weights maximum

Preventing layout shift from fonts​

/* Match fallback metrics to the web font */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-400.woff2") format("woff2");
font-display: optional;
font-weight: 400;
}

body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
}

Using font-display: optional combined with preloading often gives you the web font on repeat visits (cached) and the system font on some first visits (minimal layout shift).

JavaScript​

Code splitting​

Load only the JavaScript needed for the current page:

// Dynamic import - loaded on demand
const module = await import('./heavy-chart-library.js');

Frameworks handle this automatically:

FrameworkHow
Next.jsnext/dynamic, automatic page-level splitting
ReactReact.lazy() + Suspense
VanillaDynamic import()

Script loading​

<!-- Blocks parsing (bad for performance) -->
<script src="app.js"></script>

<!-- Downloads in parallel, executes after HTML is parsed (good) -->
<script src="app.js" defer></script>

<!-- Downloads in parallel, executes immediately when ready (for independent scripts) -->
<script src="app.js" async></script>
AttributeDownloadExecutionUse for
(none)Blocks parsingImmediatelyAlmost never
deferParallelAfter HTML parsed, in orderApp scripts
asyncParallelImmediately when ready, any orderAnalytics, ads

Tree shaking​

Bundlers (webpack, Rollup, esbuild) remove unused exports from your bundles. For it to work:

  • Use ES modules (import/export), not CommonJS (require)
  • Avoid side effects in modules
  • Mark packages as side-effect-free in package.json: "sideEffects": false

Avoiding long tasks​

A long task is any JavaScript execution that takes longer than 50ms, blocking the main thread and hurting INP.

  • Break large loops into smaller chunks with scheduler.yield(), requestAnimationFrame, or timed yields
  • Defer non-critical work to after the page loads
  • Use Web Workers for CPU-intensive computation (parsing, sorting, image processing)
  • Profile with Chrome DevTools Performance panel - look for long yellow bars

CSS​

Critical CSS​

Inline the CSS needed to render above-the-fold content in the <head>:

<head>
<style>
/* Critical: header, hero, navigation */
.header { ... }
.hero { ... }
</style>
<!-- Defer the full stylesheet -->
<link rel="preload" as="style" href="/styles.css"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>

Tools like critical (npm) extract critical CSS automatically.

Reducing CSS​

  • Remove unused CSS - tools: PurgeCSS, Tailwind's built-in purge, Coverage tab in Chrome DevTools
  • Avoid @import in CSS files - it creates sequential requests; use bundler imports instead
  • Use content-visibility: auto on below-the-fold sections - skips rendering until the element is near the viewport

Avoiding render-blocking CSS​

All <link rel="stylesheet"> tags block rendering by default. Mitigation:

  • Inline critical CSS (above)
  • Use media attributes for conditional styles: <link rel="stylesheet" href="print.css" media="print">
  • Load non-critical CSS asynchronously with the preload pattern

Caching strategy​

Caching is the single most effective performance optimization. A cached response has zero latency.

HTTP cache headers​

Cache-Control​

The primary header for controlling caching:

DirectiveMeaningUse for
public, max-age=31536000, immutableCache for 1 year, never revalidateHashed static assets (app.a1b2c3.js)
public, max-age=3600Cache for 1 hourAPI responses, HTML pages
no-cacheAlways revalidate before using cached versionHTML pages (ensures fresh content)
no-storeNever cacheSensitive/authenticated content
stale-while-revalidate=60Serve stale while fetching fresh in backgroundGood for most content

Cache busting with hashed filenames​

Include a content hash in filenames so the URL changes when the content changes:

styles.a1b2c3d4.css (hash changes when CSS changes)
app.e5f6g7h8.js (hash changes when JS changes)

These files can be cached forever (immutable). When you deploy a new version, the HTML references a new filename, bypassing the cache.

CDN caching​

CDNs cache content at edge locations close to users. Benefits:

  • Lower latency - content served from a nearby server
  • Reduced origin load - the origin handles fewer requests
  • DDoS protection - CDN absorbs traffic spikes

Set appropriate Cache-Control headers so the CDN knows what to cache and for how long.

stale-while-revalidate​

A powerful pattern for balancing freshness and speed:

Cache-Control: max-age=60, stale-while-revalidate=3600
  • For the first 60 seconds: serve from cache (fresh)
  • After 60 seconds but within 3600 seconds: serve stale response immediately, fetch fresh in the background
  • After 3600 seconds: fetch fresh (normal cache miss)

Users always get an instant response; freshness is maintained in the background.

Server and network​

HTTP/2 and HTTP/3​

ProtocolKey improvement
HTTP/1.1One request per connection (or limited pipelining)
HTTP/2Multiplexing - many requests over one connection
HTTP/3QUIC (UDP-based) - faster connection setup, better on lossy networks

With HTTP/2+, techniques like domain sharding and CSS sprites are no longer needed (they can actually hurt). Focus on reducing the total number of bytes instead.

Compression​

AlgorithmCompression ratioBrowser support
BrotliBest (~15-25% smaller than gzip)All modern browsers
gzipGoodUniversal

Most servers and CDNs support both. Brotli is the default choice for static assets.

Resource hints​

Tell the browser what to fetch ahead of time:

<!-- Preconnect: establish connection early (DNS + TCP + TLS) -->
<link rel="preconnect" href="https://fonts.googleapis.com">

<!-- Preload: fetch a critical resource early -->
<link rel="preload" as="image" href="/hero.webp">
<link rel="preload" as="font" type="font/woff2" href="/inter.woff2" crossorigin>

<!-- Prefetch: fetch a resource the user might need next (low priority) -->
<link rel="prefetch" href="/next-page.html">

<!-- DNS-prefetch: resolve DNS early (cheaper than preconnect) -->
<link rel="dns-prefetch" href="https://analytics.example.com">
HintWhen to use
preconnectThird-party origins you will definitely use (fonts, API)
preloadCritical resources the browser would discover late (fonts, LCP image)
prefetchResources for the next page (navigation)
dns-prefetchThird-party origins you might use (analytics, ads)

Performance budgets​

A performance budget sets hard limits on metrics. If a build exceeds the budget, it fails.

What to budget​

MetricExample budgetWhy
Total JS size≤ 300 KB (compressed)JS is the most expensive resource per byte
Total CSS size≤ 100 KB (compressed)Render-blocking
Total image weight≤ 500 KB per pageLargest contributor to page weight
LCP≤ 2.5 secondsCore Web Vital threshold
Total page weight≤ 1.5 MBGood baseline for mobile
Number of requests≤ 50Reduce overhead

Enforcing budgets​

  • webpack: performance.maxAssetSize and performance.maxEntrypointSize in config
  • Lighthouse CI: Set assertions in lighthouserc.json
  • bundlesize: CLI tool that fails CI if bundles exceed limits
  • Import Cost (VS Code extension): shows the size of imported packages inline
// Example: Lighthouse CI budget
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 300000 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }]
}
}
}
}

Anti-patterns​

Common mistakes that tank performance:

Anti-patternProblemFix
Render-blocking <script> in <head>Delays all renderingUse defer or move to end of <body>
Unoptimized third-party scriptsAnalytics, chat widgets, A/B testing block the main threadLoad async, defer non-critical, audit regularly
Images without dimensionsCLS from layout shiftsAlways set width/height or aspect-ratio
Lazy-loading the LCP imageDelays the most important visual elementEager-load the hero image, set fetchpriority="high"
Loading all JS upfrontHuge initial bundleCode-split per route/page
CSS @import chainsSequential network requestsBundle CSS with your build tool
Layout thrashingReading then writing DOM in a loopBatch reads, then batch writes
Excessive DOM size> 1500 nodes slows everythingVirtualize long lists, simplify structure
No caching headersEvery visit re-fetches everythingSet Cache-Control on all static assets
Uncompressed assets2-3x larger than necessaryEnable Brotli/gzip on your server

Platform-specific resources​

These guides cover performance in the context of specific platforms used in this documentation:

AEM:

Strapi:

JavaScript:

  • Performance Basics - client-side JavaScript performance patterns
  • MDN MCP Server - query live browser support for performance APIs (PerformanceObserver, fetchpriority, etc.) directly in your editor

Summary​

AreaKey takeaway
Core Web VitalsLCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1 - measure both lab and field
ImagesUse WebP/AVIF, responsive srcset, lazy-load (except LCP), always set dimensions
FontsSelf-host WOFF2 when practical, preload the critical font, and choose font-display intentionally
JavaScriptCode-split, defer scripts, break long tasks, tree-shake unused code
CSSInline critical CSS, remove unused CSS, avoid render-blocking stylesheets
CachingHash filenames for immutable caching, stale-while-revalidate for dynamic content
NetworkEnable Brotli, use HTTP/2+, preconnect to critical origins
BudgetsSet limits on JS/CSS/image size, enforce in CI, fail the build if exceeded
Anti-patternsAudit third-party scripts, avoid layout thrashing, never skip cache headers