Performance

Web Performance Optimization Techniques

Mayur Dabhi
Mayur Dabhi
March 5, 2026
22 min read

The page feels fine on your laptop over office Wi-Fi. Then someone opens it on a mid-range Android phone over 4G, stares at a blank screen while a 3 MB hero image and a pile of JavaScript download, and leaves. Search Console starts flagging "Poor URLs" for LCP, and nobody on the team is quite sure where to start.

This post is the order I'd work through it in: what Core Web Vitals actually measure, then images, JavaScript, CSS, caching, network tweaks, and the tools to check whether any of it helped.

What's Covered
  • What Core Web Vitals measure and how to collect them
  • Image formats, responsive images and lazy loading
  • How to load JavaScript and CSS without blocking rendering
  • Caching headers and service workers for repeat visits
  • Server-side optimizations and CDN configuration
  • Tools for measuring in the lab and in production

Understanding Core Web Vitals

Before changing anything, you need to know which number you're trying to move. Google boils page experience down to three metrics: how fast the main content shows up, how quickly the page reacts to input, and how much the layout jumps around. They've been a ranking signal since 2021, though in practice they matter more for your users than for your position in search results.

LCP Largest Contentful Paint 2.5s <2.5s 2.5-4s >4s Measures loading performance of main content INP Interaction to Next Paint 200ms <200ms 200-500ms >500ms Measures responsiveness to user interactions (clicks, taps, keys) CLS Cumulative Layout Shift 0.1 <0.1 0.1-0.25 >0.25 Measures visual stability during page load

Core Web Vitals and their "good", "needs improvement" and "poor" thresholds

LCP - Largest Contentful Paint

Time until the largest element (image, video, or text block) in the viewport becomes visible. Target: under 2.5 seconds.

INP - Interaction to Next Paint

Measures how quickly the page responds to user interactions. Replaced FID in March 2024. Target: under 200ms.

CLS - Cumulative Layout Shift

Quantifies unexpected layout shifts during the page lifecycle. Target: under 0.1 for a stable visual experience.

FCP - First Contentful Paint

Time until the first piece of content renders. Not a Core Web Vital, but a slow FCP usually means a slow LCP too. Target: under 1.8 seconds.

Image Optimization

On most content sites, images are the heaviest thing on the page, often by a wide margin. That makes them the first place I look, because fixing them rarely requires touching application code.

Choose the Right Format

Image Type? Photo/Complex WebP / AVIF Icons/Logos SVG Transparency WebP / PNG

Picking an image format by content type

Format Best For Compression Browser Support
AVIF Photos, complex images Often around half the size of JPEG Chrome, Firefox, Safari 16+
WebP Photos, graphics Typically 25-35% smaller than JPEG All modern browsers
SVG Icons, logos, illustrations Infinitely scalable Universal
JPEG Fallback for photos Good, lossy Universal
PNG Transparency, screenshots Larger files Universal

Implement Responsive Images

A phone that displays an image at 400px wide has no use for the 2000px original. Give the browser a few sizes and let it pick:

responsive-images.html
<!-- Using srcset for responsive images -->
<img
  src="hero-800.jpg"
  srcset="hero-400.jpg 400w,
          hero-800.jpg 800w,
          hero-1200.jpg 1200w,
          hero-1600.jpg 1600w"
  sizes="(max-width: 600px) 100vw,
         (max-width: 1200px) 50vw,
         800px"
  alt="Hero image"
  loading="lazy"
  decoding="async"
/>

<!-- Modern formats with fallback -->
<picture>
  <source srcset="image.avif" type="image/avif"/>
  <source srcset="image.webp" type="image/webp"/>
  <img src="image.jpg" alt="Description" loading="lazy"/>
</picture>
Don't Lazy Load the Hero

Add loading="lazy" to images below the fold so they only load as they approach the viewport. The mistake I see a lot is slapping it on every image, including the hero. That delays the LCP element, and Lighthouse will call it out with "Largest Contentful Paint image was lazily loaded". For the LCP image, use loading="eager" (or just leave the attribute off) plus fetchpriority="high".

JavaScript Performance

A kilobyte of JavaScript costs more than a kilobyte of image. The image only has to be downloaded and decoded; the script has to be downloaded, parsed, compiled and executed, usually on the main thread, which is also where clicks get handled. That's why heavy bundles hurt INP as much as load time.

Code Splitting

Split your JavaScript bundle into smaller chunks loaded on demand

High

Tree Shaking

Let the bundler drop unused exports (works best with ES modules and side-effect-free packages)

High

Defer Non-Critical JS

Use async/defer attributes to prevent render blocking

Medium

Loading Strategies

normal.html
<script src="app.js"></script>
HTML Parsing
Parse
Block
Execute
Parse

Blocks HTML parsing while downloading and executing. Avoid for non-critical scripts.

async.html
<script src="analytics.js" async></script>
HTML Parsing
Parse
Exec
Parse
Script Download
Download (parallel)

Downloads in parallel, executes immediately when ready. Good for independent scripts like analytics.

defer.html
<script src="app.js" defer></script>
HTML Parsing
Parse HTML
Execute JS
Script Download
Download (parallel)

Downloads in parallel, executes after HTML parsing. Best for scripts that need the DOM.

module.html
<script type="module" src="app.js"></script>

Module scripts are deferred by default, so you don't need to add defer. Add async if you want one to run as soon as it's ready.

Code Splitting with Dynamic Imports

code-splitting.js
// Instead of importing everything upfront
// import { heavyModule } from './heavy-module';

// Load only when needed (dynamic import)
async function loadFeature() {
  const { heavyModule } = await import('./heavy-module');
  heavyModule.init();
}

// React lazy loading example
const Dashboard = React.lazy(() => import('./Dashboard'));

// Route-based splitting (React Router)
const routes = [
  {
    path: '/dashboard',
    element: (
      <Suspense fallback={<Loading />}>
        <Dashboard />
      </Suspense>
    )
  }
];

CSS Performance

The browser won't paint anything until it has downloaded and parsed every stylesheet in the <head>. So the goal is simple: make the CSS needed for the first screen tiny, and get everything else out of the way.

1

Inline Critical CSS

Embed above-the-fold CSS directly in the HTML <head> to eliminate render-blocking requests for critical content.

2

Defer Non-Critical CSS

Load below-the-fold styles asynchronously using the media="print" trick or loadCSS library.

3

Remove Unused CSS

Use PurgeCSS or the Coverage tab in DevTools to find dead styles. Old Bootstrap themes and years of accumulated overrides are the usual culprits. Tailwind v3+ only generates the classes it finds in your templates, but only if the content paths actually cover all your files.

4

Minify and Compress

Use cssnano for minification and enable Gzip/Brotli compression on your server.

critical-css.html
<head>
  <!-- Critical CSS inlined -->
  <style>
    /* Above-the-fold styles only */
    body{margin:0;font-family:system-ui}
    .header{background:#000;color:#fff;padding:20px}
    .hero{min-height:60vh;display:grid;place-items:center}
  </style>

  <!-- Non-critical CSS loaded async -->
  <link rel="preload" href="/css/main.css" as="style"
        onload="this.onload=null;this.rel='stylesheet'">
  <noscript>
    <link rel="stylesheet" href="/css/main.css">
  </noscript>
</head>
Avoid Layout Thrashing

Reading and writing DOM properties in a loop causes the browser to recalculate layout repeatedly. Do all your reads first, then all your writes. In the Performance panel this shows up as purple "Layout" blocks with a "Forced reflow" warning.

Caching Strategies

The fastest request is the one that never leaves the browser. Each layer below catches what the previous one missed, and each miss costs more time.

👤 User Browser Cache near 0ms Miss CDN Edge Cache ~20-50ms Miss Server Cache ~50-100ms Miss Origin Server ~200-500ms Repeat visits Static assets Rendered pages Every miss falls through to a slower layer

Multi-layer caching architecture: Browser → CDN → Server → Origin

Cache-Control Headers

nginx.conf
# Static assets - cache for 1 year (immutable with hash)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Vary Accept-Encoding;
}

# HTML pages - revalidate each request
location ~* \.html$ {
    add_header Cache-Control "no-cache, must-revalidate";
    etag on;
}

# API responses - short cache with stale-while-revalidate
location /api/ {
    add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
}
Two Things That Bite in Production
  • nginx add_header inheritance: if a location block has any add_header of its own, it drops every add_header inherited from the server block. Your security headers (HSTS, CSP) silently disappear on exactly the routes where you added caching. Check with curl -I after deploying.
  • immutable only works with hashed filenames: a one-year cache on app.js means users keep the old file after a deploy. Only use it for files like app.3f9a1c.js that your build tool renames whenever the content changes.

Service Worker Caching

A service worker sits between the page and the network, so you decide per request whether to answer from cache. It also keeps the site usable offline. Pick one strategy per route; the two handlers below are alternatives, not something to register together:

sw.js
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
  '/',
  '/css/main.css',
  '/js/app.js',
  '/images/logo.svg'
];

// Cache-First Strategy
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(cached => cached || fetch(event.request))
  );
});

// Stale-While-Revalidate Strategy
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then(async (cache) => {
      const cached = await cache.match(event.request);
      const fetched = fetch(event.request).then((response) => {
        cache.put(event.request, response.clone());
        return response;
      });
      return cached || fetched;
    })
  );
});

Network Optimizations

Most of these are server or hosting settings rather than code changes, so they're cheap to try:

Enable Compression

Use Brotli (preferred) or Gzip for HTML, CSS, JS and JSON. Text compresses very well, so transfer sizes usually drop by more than half.

Preconnect to Origins

Use <link rel="preconnect"> for the two or three third-party origins you hit on every page, so DNS, TCP and TLS are done before the request is needed.

Use a CDN

Serve assets from edge locations close to users. The further your users are from your origin, the bigger the difference.

Enable HTTP/2 or HTTP/3

Many requests share one connection instead of queuing, which makes old tricks like domain sharding and sprite sheets unnecessary.

resource-hints.html
<head>
  <!-- Preconnect to critical third-party origins -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link rel="preconnect" href="https://cdn.example.com">
  
  <!-- DNS prefetch for less critical origins -->
  <link rel="dns-prefetch" href="https://analytics.example.com">
  
  <!-- Preload critical resources -->
  <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
  <link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
  
  <!-- Prefetch next page for likely navigation -->
  <link rel="prefetch" href="/next-page.html">
</head>

Go easy on preload. Everything you preload competes with everything else for bandwidth, and if a preloaded file isn't used soon after load, Chrome logs "The resource ... was preloaded using link preload but not used within a few seconds". Fonts are the classic trap: leave off crossorigin and the browser downloads the font twice.

Performance Measurement Tools

Lab tools like Lighthouse run on one simulated device and are good for debugging. Field data comes from real visitors and is what Google actually uses. You want both, and when they disagree, trust the field data:

Tool Best For Type
Lighthouse Overall performance audits, Core Web Vitals Lab data
Chrome DevTools Network waterfall, JS profiling, rendering Lab data
WebPageTest Detailed waterfalls, filmstrip view, comparison Lab data
PageSpeed Insights Real user data + Lighthouse, Core Web Vitals Field + Lab
Chrome UX Report Real-world performance data at scale Field data
Web Vitals JS Real-time monitoring in production Field data
web-vitals-monitoring.js
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics({ name, delta, id }) {
  // Send to your analytics endpoint
  navigator.sendBeacon('/analytics', JSON.stringify({
    metric: name,
    value: delta,
    id: id,
    page: window.location.pathname
  }));
}

// Measure all Core Web Vitals
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

Performance Checklist

I go through this list when auditing a site. Most of it can be checked in an afternoon:

Images

  • Use modern formats (WebP, AVIF) with fallbacks
  • Implement responsive images with srcset
  • Add loading="lazy" to below-fold images
  • Set explicit width and height attributes
  • Compress images without visible quality loss

JavaScript

  • Implement code splitting for routes/features
  • Use async/defer for non-critical scripts
  • Tree shake unused code in production builds
  • Minimize third-party scripts
  • Avoid long-running JavaScript tasks (>50ms)

CSS

  • Inline critical above-the-fold CSS
  • Remove unused CSS (PurgeCSS)
  • Minify CSS in production
  • Avoid CSS @import (use link instead)
  • Use efficient selectors

Server & Network

  • Enable Brotli/Gzip compression
  • Configure proper cache headers
  • Use a CDN for static assets
  • Enable HTTP/2 or HTTP/3
  • Preconnect to critical third-party origins

Where I'd Start

If you only have a day, these give the most back for the least effort:

Convert Images to WebP

Usually noticeably smaller than JPEG at similar visual quality

⚡ Easy

Add Width/Height to Images

Prevents CLS by reserving space before load

⚡ Easy

Defer Third-Party Scripts

Load analytics and widgets after critical content

⚡ Easy

Enable Brotli Compression

Smaller text assets than Gzip, and every modern browser supports it

⚙️ Config

Mistakes I See Most Often (and When Not to Bother)

Chasing a Lighthouse score of 100. Lighthouse runs one throttled load on one simulated device. If your field data in Search Console or CrUX is already "good" for all three metrics, squeezing out the last few lab points is rarely worth a week of work.

Optimizing before measuring. It's easy to spend days on a service worker when the real problem is an uncompressed 2 MB hero image or a chat widget blocking the main thread. Open the Network and Performance panels first and fix the biggest bar in the waterfall.

Adding a service worker you don't need. For a marketing site or blog, good Cache-Control headers and a CDN get you most of the benefit. A service worker adds a cache you have to version and invalidate, and a buggy one can keep serving stale pages until users clear site data. I'd only reach for one if you need offline support or have an app shell that really benefits.

Code splitting everything. Splitting a small app into dozens of tiny chunks can make things slower, since each chunk is another request and another waterfall step. Split by route and by heavy, rarely used features (editors, charts, maps), not by every component.

Fixing it once and never checking again. Performance regresses quietly: a new tracking script here, an unoptimized upload there. Send the web-vitals data to your analytics and look at it after big releases, or add a Lighthouse CI budget so a pull request fails when the bundle grows past a limit.

Performance Optimization Core Web Vitals Speed Caching Web Development
Mayur Dabhi

Written by Mayur Dabhi

Full Stack Developer passionate about building fast, accessible, and user-friendly web applications with modern technologies.