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 with requestIdleCallback or scheduler.yield()
  • 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
  • Use font-display: optional to avoid font-swap layout shifts

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 reflows on swap
optionalShow fallback, use web font only if already cachedNone -- no swap
fallbackBrief invisible period (100ms), then fallback, then swapLow
blockInvisible text for up to 3 secondsNone, but FOIT (flash of invisible text)

Recommendation: Use font-display: optional for body text (avoids all layout shift). Use swap only if the brand font is critical to the design.

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 instead of loading from Google Fonts -- avoids a DNS lookup, connection, and the Google Fonts CSS file
  • 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 gives you the web font on repeat visits (cached) and the system font on first visit (no 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 with requestIdleCallback or scheduler.yield()
  • 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'">
</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:

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, preload critical font, font-display: optional for zero CLS
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