Frontend Performance Optimization Techniques
A practical guide to frontend performance optimization covering Core Web Vitals, critical rendering path, image optimization, code splitting, and caching strategies.
Frontend Performance Optimization Techniques
Overview
Performance is not a feature -- it is the feature. A page that takes four seconds to load loses 25% of visitors before anything renders. Google uses Core Web Vitals as a ranking signal. Your users judge your brand by how fast your page feels before they read a single word.
After a decade of building production web applications, I have watched performance optimization go from a nice-to-have to a non-negotiable requirement. The good news is that 80% of frontend performance gains come from a handful of well-understood techniques. The bad news is that most teams still ship pages bloated with unoptimized images, render-blocking scripts, and third-party tags that undo every optimization they make.
This article covers the full performance optimization toolkit: measuring what matters, fixing what is slow, and building guardrails that keep it fast. Every technique includes working code and measurable outcomes.
Prerequisites
- Solid understanding of HTML, CSS, and JavaScript
- Familiarity with browser DevTools (Chrome preferred)
- Basic understanding of HTTP headers and caching
- A web application or page to optimize (any framework or vanilla)
- Node.js installed for build tooling examples
Core Web Vitals
Google defines three metrics that capture the user-perceived performance of a page. These are not abstract benchmarks -- they map directly to user experience.
Largest Contentful Paint (LCP)
LCP measures how long it takes for the largest visible element to render. This is usually a hero image, heading, or text block. Target: under 2.5 seconds.
LCP is the metric most affected by network conditions, server response time, and render-blocking resources. It is also the easiest to improve because the fixes are mechanical: reduce server response time, eliminate render-blocking resources, and optimize the LCP element itself.
Interaction to Next Paint (INP)
INP replaced First Input Delay (FID) in March 2024. While FID only measured the delay of the first interaction, INP measures the worst-case responsiveness across all interactions during a page visit. Target: under 200 milliseconds.
INP failures come from heavy JavaScript execution on the main thread. Long tasks that block the event loop mean clicks and keystrokes feel sluggish. The fix is breaking work into smaller chunks, moving computation off the main thread, and keeping event handlers lean.
Cumulative Layout Shift (CLS)
CLS measures visual stability -- how much the page layout shifts unexpectedly during loading. Target: under 0.1.
Layout shifts happen when elements load without reserved dimensions: images without width and height, fonts that swap and reflow text, or dynamically injected content that pushes elements around. Every one of these is preventable.
Measuring Performance
You cannot improve what you do not measure. Use these tools in combination -- they answer different questions.
Lighthouse
Lighthouse runs a simulated audit against your page and produces scores for Performance, Accessibility, Best Practices, and SEO. Run it from Chrome DevTools (Audits tab), the command line, or CI.
# Install Lighthouse CLI
npm install -g lighthouse
# Run an audit and output JSON
lighthouse https://yoursite.com --output=json --output-path=./report.json
# Run with specific device emulation
lighthouse https://yoursite.com --preset=desktop --output=html
Lighthouse is excellent for identifying specific issues (render-blocking resources, unoptimized images, missing attributes) but its scores are synthetic. A perfect Lighthouse score does not guarantee fast real-world performance.
WebPageTest
WebPageTest tests from real browsers in real locations over real networks. It produces waterfall charts that show exactly when each resource loads and how it affects rendering. Use the filmstrip view to see what the user sees at each point in time.
Key things to look for in a WebPageTest waterfall:
- Time to First Byte (TTFB) -- if this is slow, no frontend fix helps
- Render-blocking requests in the critical path
- Late-loading LCP elements
- Third-party requests that block rendering
Chrome DevTools Performance Tab
The Performance tab records a timeline of everything the browser does: parsing, scripting, rendering, painting. This is where you diagnose INP issues and find long tasks.
Record a page load, then look for:
- Long tasks (red triangles) blocking the main thread
- Layout thrashing (forced reflows in rapid succession)
- Expensive paint operations
- JavaScript execution that delays rendering
Critical Rendering Path Optimization
The critical rendering path is the sequence of steps the browser takes to convert HTML, CSS, and JavaScript into pixels on screen. Every unnecessary step in this path delays first paint.
The browser must:
- Parse HTML and build the DOM
- Parse CSS and build the CSSOM
- Combine DOM and CSSOM into a render tree
- Calculate layout (geometry of each node)
- Paint pixels to screen
Anything that blocks steps 1-3 delays everything downstream. This is where render-blocking resources hurt.
Reducing Render-Blocking Resources
By default, <script> tags block HTML parsing and <link rel="stylesheet"> blocks rendering. Both are fixable.
Script loading strategies:
<!-- Blocks parsing (worst case) -->
<script src="/js/app.js"></script>
<!-- Downloads in parallel, executes after HTML parsing (preserves order) -->
<script defer src="/js/app.js"></script>
<!-- Downloads in parallel, executes as soon as ready (no order guarantee) -->
<script async src="/js/analytics.js"></script>
Use defer for application scripts that depend on DOM or execution order. Use async for independent scripts like analytics. Never put a render-critical script in the <head> without defer or async.
Critical CSS inlining:
Instead of waiting for an external stylesheet to load, inline the CSS needed for above-the-fold content directly in the <head>:
<head>
<!-- Critical CSS inlined -->
<style>
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
.hero { min-height: 60vh; display: flex; align-items: center; }
.hero h1 { font-size: 2.5rem; color: #1a1a2e; }
.nav { display: flex; padding: 1rem 2rem; background: #fff; }
</style>
<!-- Full stylesheet loaded asynchronously -->
<link rel="preload" href="/css/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/styles.css"></noscript>
</head>
Extract critical CSS with tools like critical (by Addy Osmani) or critters for webpack:
var critical = require('critical');
critical.generate({
base: 'dist/',
src: 'index.html',
css: ['dist/css/styles.css'],
width: 1300,
height: 900,
inline: true,
target: {
html: 'index-critical.html',
css: 'critical.css'
}
});
Image Optimization
Images account for 50% or more of page weight on most sites. Getting images right has an outsized impact on LCP and bandwidth.
Format Selection
| Format | Best For | Browser Support |
|---|---|---|
| WebP | General purpose, replaces JPEG/PNG | 97%+ |
| AVIF | Best compression, photos and illustrations | 92%+ |
| SVG | Icons, logos, simple illustrations | Universal |
| JPEG | Fallback for older browsers | Universal |
| PNG | Transparency where WebP/AVIF are not an option | Universal |
Use the <picture> element to serve modern formats with fallbacks:
<picture>
<source srcset="/images/hero.avif" type="image/avif">
<source srcset="/images/hero.webp" type="image/webp">
<img src="/images/hero.jpg" alt="Dashboard screenshot"
width="1200" height="630"
loading="eager"
fetchpriority="high">
</picture>
Responsive Images with srcset
Serve appropriately sized images based on viewport width:
<img srcset="/images/hero-400.webp 400w,
/images/hero-800.webp 800w,
/images/hero-1200.webp 1200w,
/images/hero-1600.webp 1600w"
sizes="(max-width: 600px) 100vw,
(max-width: 1200px) 80vw,
1200px"
src="/images/hero-800.webp"
alt="Dashboard screenshot"
width="1200" height="630">
Lazy Loading and CLS Prevention
Lazy load images below the fold. Always set width and height attributes so the browser reserves space before the image loads (preventing CLS):
<!-- Above the fold: load eagerly with high priority -->
<img src="/images/hero.webp" alt="Hero"
width="1200" height="630"
loading="eager" fetchpriority="high">
<!-- Below the fold: lazy load -->
<img src="/images/feature.webp" alt="Feature screenshot"
width="800" height="450"
loading="lazy">
The loading="lazy" attribute is supported in all modern browsers and requires zero JavaScript. Do not lazy load your LCP image -- that is the single biggest LCP mistake I see in audits.
Font Loading Strategies
Custom fonts cause two problems: they block rendering while downloading, and they trigger layout shifts when they swap in.
font-display
The font-display descriptor controls how the browser handles font loading:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap; /* Show fallback immediately, swap when loaded */
}
Options:
swap-- shows fallback text immediately, swaps when font loads. Good for body text.optional-- uses fallback if font is not cached. Best for performance, acceptable flash.fallback-- short invisible period, then fallback. Middle ground.
Preloading Fonts
Preload fonts to start the download early in the page load waterfall:
<link rel="preload" href="/fonts/inter-var.woff2"
as="font" type="font/woff2" crossorigin>
The crossorigin attribute is required for font preloads even when the font is self-hosted. Without it, the browser downloads the font twice.
Reducing Font Impact on CLS
Match your fallback font metrics to your web font to minimize layout shift during the swap:
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
size-adjust: 107%;
}
body {
font-family: 'Inter', 'Inter Fallback', sans-serif;
}
JavaScript Bundle Size Reduction
Large JavaScript bundles are the primary cause of slow INP scores. The browser must download, parse, compile, and execute every byte of JavaScript before it becomes interactive.
Code Splitting and Lazy Loading
Split your bundle so users only download the code they need for the current page:
// webpack.config.js
module.exports = {
entry: {
main: './src/main.js',
admin: './src/admin.js'
},
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
}
};
For route-based lazy loading with dynamic imports:
// Load a module only when needed
function loadEditor() {
return import(/* webpackChunkName: "editor" */ './editor.js')
.then(function(module) {
return module.default;
});
}
document.getElementById('edit-btn').addEventListener('click', function() {
loadEditor().then(function(Editor) {
var editor = new Editor('#content');
editor.init();
});
});
Tree Shaking
Tree shaking eliminates dead code from your bundle. It works best with ES module syntax, but the key is structuring imports to be specific:
// Bad: imports entire library (lodash is 71KB minified)
var _ = require('lodash');
_.debounce(handler, 300);
// Better: import only what you need
var debounce = require('lodash/debounce');
debounce(handler, 300);
Audit your bundle with webpack-bundle-analyzer to find unexpectedly large dependencies:
// webpack.config.js
var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
};
Minification and Compression
Minification removes whitespace, shortens variable names, and eliminates dead code. Compression reduces the bytes sent over the wire.
Brotli vs Gzip:
- Brotli achieves 15-25% better compression than gzip on text assets
- Brotli at compression level 11 is slow -- use it for static pre-compression only
- Gzip is faster for dynamic compression and is the universal fallback
Configure both on your server:
var express = require('express');
var shrinkRay = require('shrink-ray-current');
var app = express();
// Brotli + gzip compression with sensible defaults
app.use(shrinkRay({
brotli: { quality: 4 } // Level 4 for dynamic, pre-compress static at 11
}));
// Serve pre-compressed static files
app.use(express.static('dist', {
setHeaders: function(res, path) {
if (path.endsWith('.br')) {
res.set('Content-Encoding', 'br');
}
}
}));
HTTP/2 and Resource Prioritization
HTTP/2 multiplexes requests over a single connection, eliminating the need for domain sharding and reducing the cost of multiple small requests. But it introduces a new challenge: prioritization.
Resource Hints
Tell the browser what to fetch early and what to defer:
<!-- Establish early connection to critical third-party origin -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://analytics.example.com">
<!-- Preload critical resources needed for current page -->
<link rel="preload" href="/css/critical.css" as="style">
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/images/hero.webp" as="image">
<!-- Prefetch resources needed for likely next navigation -->
<link rel="prefetch" href="/js/dashboard.js">
Use preconnect for origins you will definitely need. Use dns-prefetch as a cheaper fallback for lower-priority origins. Use preload sparingly -- preloading too many resources competes with truly critical requests.
fetchpriority Attribute
The fetchpriority attribute gives you explicit control over download priority:
<!-- LCP image: fetch first -->
<img src="/hero.webp" fetchpriority="high" alt="Hero">
<!-- Below-fold images: lower priority -->
<img src="/footer-logo.webp" fetchpriority="low" alt="Logo" loading="lazy">
<!-- Critical CSS fetch: high priority -->
<link rel="preload" href="/critical.css" as="style" fetchpriority="high">
DOM Size and Efficient Rendering
A DOM with more than 1,500 nodes slows down every operation: style calculations, layout, paint, and JavaScript DOM queries. Keep your DOM lean.
Efficient Event Handlers
Use event delegation instead of attaching handlers to every element:
// Bad: handler on every list item
var items = document.querySelectorAll('.item');
for (var i = 0; i < items.length; i++) {
items[i].addEventListener('click', function(e) {
handleItemClick(e.target);
});
}
// Good: single delegated handler
document.getElementById('item-list').addEventListener('click', function(e) {
var item = e.target.closest('.item');
if (item) {
handleItemClick(item);
}
});
Mark scroll and touch handlers as passive so the browser does not wait for your handler before scrolling:
document.addEventListener('scroll', function(e) {
updateScrollProgress(window.scrollY);
}, { passive: true });
document.addEventListener('touchstart', function(e) {
handleTouchStart(e);
}, { passive: true });
requestAnimationFrame for Visual Updates
Never update the DOM from a scroll or resize handler directly. Batch visual updates with requestAnimationFrame:
var ticking = false;
function onScroll() {
if (!ticking) {
requestAnimationFrame(function() {
updateHeader(window.scrollY);
ticking = false;
});
ticking = true;
}
}
window.addEventListener('scroll', onScroll, { passive: true });
Web Workers for Heavy Computation
Move CPU-intensive work off the main thread to keep the UI responsive:
// worker.js
self.onmessage = function(e) {
var data = e.data;
var result = processLargeDataset(data.items);
self.postMessage({ result: result });
};
function processLargeDataset(items) {
// Heavy computation: sorting, filtering, aggregation
var processed = items.filter(function(item) {
return item.score > calculateThreshold(items);
});
return processed.sort(function(a, b) {
return b.score - a.score;
});
}
// main.js
var worker = new Worker('/js/worker.js');
worker.onmessage = function(e) {
renderResults(e.data.result);
};
function analyzeData(items) {
// Show loading state, then offload computation
showSpinner();
worker.postMessage({ items: items });
}
Caching Strategies
Effective caching eliminates network requests entirely for returning visitors.
Cache-Control Headers
var express = require('express');
var app = express();
// Hashed static assets: cache indefinitely
app.use('/assets', express.static('dist/assets', {
maxAge: '1y',
immutable: true,
setHeaders: function(res) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
}
}));
// HTML pages: always revalidate
app.use(express.static('dist', {
setHeaders: function(res, filePath) {
if (filePath.endsWith('.html')) {
res.set('Cache-Control', 'no-cache');
}
}
}));
The pattern is: hash your asset filenames (e.g., app.a3b4c5.js), cache them forever, and let the HTML reference the new hash when you deploy. HTML itself should always revalidate.
Service Worker Caching
For fine-grained control, use a service worker:
// sw.js
var CACHE_NAME = 'app-v1';
var STATIC_ASSETS = [
'/',
'/css/styles.css',
'/js/app.js',
'/images/logo.svg'
];
self.addEventListener('install', function(e) {
e.waitUntil(
caches.open(CACHE_NAME).then(function(cache) {
return cache.addAll(STATIC_ASSETS);
})
);
});
self.addEventListener('fetch', function(e) {
e.respondWith(
caches.match(e.request).then(function(cached) {
// Stale-while-revalidate: serve cached, update in background
var fetchPromise = fetch(e.request).then(function(response) {
var responseClone = response.clone();
caches.open(CACHE_NAME).then(function(cache) {
cache.put(e.request, responseClone);
});
return response;
});
return cached || fetchPromise;
})
);
});
Third-Party Script Impact and Mitigation
Third-party scripts (analytics, ads, chat widgets, A/B testing) are the silent killer of web performance. A single poorly-behaved tag can add 500ms+ to your LCP.
Strategies for containment:
<!-- Load third-party scripts after page load -->
<script>
window.addEventListener('load', function() {
var script = document.createElement('script');
script.src = 'https://analytics.example.com/tracker.js';
script.async = true;
document.body.appendChild(script);
});
</script>
<!-- Use web worker-based solutions like Partytown -->
<script>
// Partytown moves third-party scripts to a web worker
partytown = {
forward: ['dataLayer.push']
};
</script>
<script src="/partytown/partytown.js"></script>
<script type="text/partytown" src="https://analytics.example.com/tracker.js"></script>
Audit third-party impact by blocking scripts in DevTools Network panel and measuring the difference.
Performance Budgets
A performance budget is a set of limits on metrics that matter. Without budgets, performance degrades with every feature added.
// webpack performance budget
module.exports = {
performance: {
maxAssetSize: 150000, // 150KB per asset
maxEntrypointSize: 300000, // 300KB per entry point
hints: 'error' // Fail the build if exceeded
}
};
Example budget for a content site:
| Metric | Budget | Rationale |
|---|---|---|
| LCP | < 2.0s | Good Core Web Vitals |
| INP | < 150ms | Good Core Web Vitals |
| CLS | < 0.05 | Good Core Web Vitals |
| Total JS | < 200KB | Keeps parse time low |
| Total CSS | < 50KB | Avoids render-blocking bloat |
| Total image weight | < 500KB | Reasonable for most pages |
| Third-party requests | < 5 | Limits external dependencies |
Complete Working Example: Before and After
Here is a real-world case study of optimizing a product landing page. The page had a Lighthouse Performance score of 42 with an LCP of 4.2 seconds.
Before: The Slow Page
<!DOCTYPE html>
<html>
<head>
<title>Product Landing Page</title>
<!-- Render-blocking full stylesheet (280KB uncompressed) -->
<link rel="stylesheet" href="/css/bootstrap.css">
<link rel="stylesheet" href="/css/styles.css">
<link rel="stylesheet" href="/css/animations.css">
<!-- Render-blocking fonts from Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=block" rel="stylesheet">
<!-- Synchronous scripts in head -->
<script src="/js/jquery-3.6.0.min.js"></script>
<script src="/js/bootstrap.bundle.js"></script>
<script src="https://analytics.example.com/tracker.js"></script>
</head>
<body>
<nav><!-- navigation --></nav>
<!-- Hero image: 2.4MB uncompressed JPEG, no dimensions -->
<img src="/images/hero-photo.jpg" alt="Product hero">
<section class="features">
<!-- 12 feature images, all loaded eagerly, no dimensions set -->
<img src="/images/feature-1.png" alt="Feature 1">
<img src="/images/feature-2.png" alt="Feature 2">
<!-- ... 10 more -->
</section>
<script src="/js/app.js"></script> <!-- 450KB unminified -->
<script src="/js/carousel.js"></script>
<script src="/js/form-validation.js"></script>
</body>
</html>
Lighthouse Results (Before):
- Performance Score: 42
- LCP: 4.2s (hero image)
- INP: 380ms (heavy jQuery operations)
- CLS: 0.35 (images without dimensions, font swap)
- Total page weight: 4.1MB
- Requests: 38
Identifying Bottlenecks
Running Lighthouse flagged these issues:
- Render-blocking resources -- 3 CSS files and 3 scripts in
<head>(estimated savings: 1.8s) - Unoptimized images -- hero image was 2.4MB JPEG, could be 180KB WebP (estimated savings: 2.2MB)
- No image dimensions -- caused CLS of 0.35
- Large JavaScript bundle -- 450KB unminified app.js (estimated savings: 320KB with minification + tree shaking)
- Synchronous third-party script -- analytics blocking rendering by 400ms
After: The Optimized Page
<!DOCTYPE html>
<html>
<head>
<title>Product Landing Page</title>
<!-- Preconnect to critical origins -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Preload LCP image and critical font -->
<link rel="preload" href="/images/hero-photo.webp" as="image"
type="image/webp" fetchpriority="high">
<link rel="preload" href="/fonts/inter-var.woff2" as="font"
type="font/woff2" crossorigin>
<!-- Critical CSS inlined -->
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter','Inter Fallback',sans-serif;line-height:1.6;color:#1a1a2e}
.nav{display:flex;align-items:center;padding:1rem 2rem;background:#fff}
.hero{position:relative;min-height:70vh;display:flex;align-items:center}
.hero img{width:100%;height:auto;display:block}
.hero h1{font-size:2.5rem;font-weight:700}
@font-face{font-family:'Inter Fallback';src:local('Arial');
ascent-override:90%;descent-override:22%;size-adjust:107%}
@font-face{font-family:'Inter';src:url('/fonts/inter-var.woff2') format('woff2');
font-weight:100 900;font-display:swap}
</style>
<!-- Full stylesheets loaded asynchronously -->
<link rel="preload" href="/css/styles.min.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/styles.min.css"></noscript>
</head>
<body>
<nav class="nav"><!-- navigation --></nav>
<section class="hero">
<!-- Optimized hero: WebP with AVIF, explicit dimensions, high priority -->
<picture>
<source srcset="/images/hero-photo.avif" type="image/avif">
<source srcset="/images/hero-photo.webp" type="image/webp">
<img src="/images/hero-photo.jpg" alt="Product hero"
width="1200" height="630"
loading="eager" fetchpriority="high">
</picture>
</section>
<section class="features">
<!-- Below-fold images: lazy loaded with dimensions -->
<img src="/images/feature-1.webp" alt="Feature 1"
width="400" height="300" loading="lazy">
<img src="/images/feature-2.webp" alt="Feature 2"
width="400" height="300" loading="lazy">
<!-- ... -->
</section>
<!-- Application JS: deferred, code-split, minified -->
<script defer src="/js/app.min.js"></script>
<!-- Third-party scripts: loaded after page load -->
<script>
window.addEventListener('load', function() {
var s = document.createElement('script');
s.src = 'https://analytics.example.com/tracker.js';
s.async = true;
document.body.appendChild(s);
});
</script>
</body>
</html>
Optimization Summary:
| Change | Impact |
|---|---|
| Inlined critical CSS, async-loaded full stylesheet | Eliminated 1.2s render-blocking CSS |
Replaced synchronous scripts with defer |
Eliminated 600ms script blocking |
| Converted images to WebP/AVIF with srcset | Reduced image weight from 3.2MB to 420KB |
| Added width/height to all images | CLS dropped from 0.35 to 0.02 |
Self-hosted font with font-display: swap + fallback metrics |
Eliminated font render-blocking, reduced CLS |
| Minified + tree-shook JS bundle | JS reduced from 450KB to 128KB |
| Lazy loaded below-fold images | Initial payload dropped by 1.8MB |
| Deferred analytics to after page load | Removed 400ms third-party blocking |
Lighthouse Results (After):
- Performance Score: 96 (was 42)
- LCP: 1.8s (was 4.2s)
- INP: 85ms (was 380ms)
- CLS: 0.02 (was 0.35)
- Total initial page weight: 680KB (was 4.1MB)
- Requests: 12 (was 38)
The LCP improvement from 4.2s to 1.8s came primarily from three changes: inlining critical CSS (eliminated 1.2s render block), converting the hero image to WebP (reduced from 2.4MB to 145KB), and preloading the hero image so it started downloading immediately.
Common Issues and Troubleshooting
LCP Image Not Improving Despite Optimization
If your LCP image is served via CSS background-image, the browser cannot discover it during HTML parsing. Move it to an <img> tag with fetchpriority="high" and rel="preload" so the preload scanner finds it immediately. Background images discovered late are one of the most common LCP failures.
CLS Spikes from Dynamic Content
Ads, embeds, and dynamically injected banners cause layout shifts because they insert content without reserving space. Use CSS min-height on container elements to reserve the expected space:
.ad-slot {
min-height: 250px; /* Reserve space for ad */
contain: layout; /* Prevent layout impact on rest of page */
}
Service Worker Serving Stale Content
If your service worker caches HTML aggressively, users see old content after deploys. Use a network-first strategy for HTML and a cache-first strategy for hashed assets:
self.addEventListener('fetch', function(e) {
var url = new URL(e.request.url);
if (e.request.mode === 'navigate') {
// HTML: network first, fall back to cache
e.respondWith(
fetch(e.request).catch(function() {
return caches.match(e.request);
})
);
} else {
// Assets: cache first
e.respondWith(
caches.match(e.request).then(function(cached) {
return cached || fetch(e.request);
})
);
}
});
Bundle Size Creeping Up After Tree Shaking
Tree shaking only works when modules have no side effects. If a library runs initialization code at import time, the bundler cannot remove it. Check your package.json for the sideEffects field and audit imports with webpack-bundle-analyzer. Large libraries like moment.js (330KB) should be replaced with lighter alternatives like date-fns (tree-shakeable) or dayjs (2KB).
Preload Warnings in Console
If you preload a resource but do not use it within 3 seconds, Chrome warns you. This wastes bandwidth and can delay higher-priority resources. Only preload resources that are consumed on the current page load path. If a resource is only needed on certain pages, conditionally add the preload tag.
Best Practices
Measure before optimizing. Run Lighthouse, WebPageTest, and check real-user data (CrUX) before making changes. Fix the bottleneck that has the biggest impact first, not the one that is easiest.
Set and enforce performance budgets. Add bundle size limits to your build configuration and fail CI when budgets are exceeded. Performance regressions are much cheaper to catch in a pull request than in production.
Prioritize the LCP element. Identify what your LCP element is (usually a hero image or heading), then ensure it loads as fast as possible: preload it, inline critical CSS above it, remove render-blocking resources before it, and use
fetchpriority="high".Always specify image dimensions. Every
<img>tag should havewidthandheightattributes. This single change eliminates most CLS issues. For responsive images, the browser uses the aspect ratio from these attributes to reserve space.Defer everything that is not critical. Scripts, stylesheets, images, and third-party tags that are not needed for the initial viewport should be deferred, lazy loaded, or loaded after the
loadevent. The less work the browser does before first paint, the faster LCP.Self-host critical resources. Fonts, critical scripts, and CSS should be served from your own domain. This eliminates DNS lookups, connection negotiations, and the risk of a third-party CDN being slow. Self-hosted fonts with
font-display: swapand metric-matched fallbacks eliminate the two biggest font performance problems.Audit third-party scripts quarterly. Third-party tags accumulate over time. Each one adds DNS lookups, connections, and JavaScript execution. Remove tags that are no longer needed and defer the rest. Use a tag manager with trigger conditions so tags only fire when needed.
Use
containCSS property for complex layouts. Thecontainproperty tells the browser that an element's contents are independent of the rest of the page, allowing it to skip recalculating layout for elements outside the containment boundary:
.card {
contain: content; /* Layout + paint containment */
}
.sidebar {
contain: strict; /* Full containment: size + layout + paint + style */
}
References
- Web Vitals -- Google's Core Web Vitals documentation
- Lighthouse -- Chrome Lighthouse documentation
- WebPageTest -- Real browser performance testing
- Resource Hints -- Preconnect, prefetch, and preload
- font-display -- MDN font-display reference
- Service Worker API -- MDN Service Worker documentation
- HTTP/2 Push and Resource Prioritization -- Resource priority and fetch priority
- CSS Containment -- MDN CSS contain property reference