Introduction
Frontend performance directly impacts business outcomes: a 100ms improvement in load time can increase conversion rates by 1%. Google’s Core Web Vitals are now ranking signals. This guide covers the techniques that move the needle โ measured, not assumed.
Prerequisites: Basic knowledge of JavaScript, HTML, CSS, and a build tool (Vite or webpack).
Core Web Vitals: What to Measure
Google’s Core Web Vitals are the metrics that matter most for user experience and SEO:
| Metric | What it measures | Good | Needs Work | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Loading performance | โค 2.5s | 2.5-4s | > 4s |
| INP (Interaction to Next Paint) | Responsiveness | โค 200ms | 200-500ms | > 500ms |
| CLS (Cumulative Layout Shift) | Visual stability | โค 0.1 | 0.1-0.25 | > 0.25 |
Measure first, optimize second. Use these tools:
# Lighthouse CLI
npm install -g lighthouse
lighthouse https://yoursite.com --view
# WebPageTest
# https://www.webpagetest.org/
# Chrome DevTools: Performance tab, Lighthouse tab
# PageSpeed Insights: https://pagespeed.web.dev/
Bundle Optimization
Code Splitting
Don’t ship all your JavaScript upfront. Split by route and load on demand:
// React: lazy load routes
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Admin = lazy(() => import('./pages/Admin'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
);
}
// Vite: manual chunk splitting
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Vendor chunk: rarely changes, cached longer
vendor: ['react', 'react-dom', 'react-router-dom'],
// Feature chunk: load only when needed
charts: ['recharts', 'd3'],
editor: ['@monaco-editor/react'],
},
},
},
},
});
Tree Shaking
Ensure unused code is eliminated:
// package.json โ mark package as side-effect free
{
"sideEffects": false
}
// BAD: imports entire lodash (~70KB)
import _ from 'lodash';
const result = _.groupBy(data, 'category');
// GOOD: imports only what you need (~1KB)
import groupBy from 'lodash/groupBy';
const result = groupBy(data, 'category');
// BETTER: use native alternatives
const result = Object.groupBy(data, item => item.category); // ES2024
Analyze Your Bundle
# Vite
npm install rollup-plugin-visualizer
# Add to vite.config.ts, run build, opens browser with treemap
# webpack
npm install webpack-bundle-analyzer
Look for:
- Duplicate dependencies (same library bundled twice)
- Large libraries with small usage (import only what you need)
- Development-only code in production bundle
JavaScript Performance
Defer Non-Critical Scripts
<!-- BAD: blocks HTML parsing -->
<script src="analytics.js"></script>
<!-- GOOD: defer โ executes after HTML parsed, in order -->
<script src="app.js" defer></script>
<!-- GOOD: async โ executes as soon as downloaded, out of order -->
<script src="analytics.js" async></script>
Rule: defer for scripts that need the DOM; async for independent scripts (analytics, ads).
Avoid Long Tasks
Tasks over 50ms block the main thread and cause poor INP:
// BAD: processes 10,000 items synchronously, blocks UI
function processAll(items) {
return items.map(item => heavyTransform(item));
}
// GOOD: yield to browser between chunks
async function processInChunks(items, chunkSize = 100) {
const results = [];
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
results.push(...chunk.map(item => heavyTransform(item)));
// Yield to browser โ allows rendering and input handling
await new Promise(resolve => setTimeout(resolve, 0));
}
return results;
}
// BEST: use Web Workers for CPU-intensive work
const worker = new Worker('/workers/processor.js');
worker.postMessage({ items });
worker.onmessage = (e) => console.log('Done:', e.data);
Debounce and Throttle
// Debounce: wait until user stops typing
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const handleSearch = debounce(async (query) => {
const results = await searchAPI(query);
setResults(results);
}, 300);
// Throttle: limit to once per interval
function throttle(fn, interval) {
let lastCall = 0;
return (...args) => {
const now = Date.now();
if (now - lastCall >= interval) {
lastCall = now;
fn(...args);
}
};
}
const handleScroll = throttle(() => {
updateScrollPosition();
}, 100);
Image Optimization
Images are typically the largest assets on a page and the biggest opportunity for LCP improvement.
Modern Formats
<!-- Use WebP/AVIF with fallback -->
<picture>
<source type="image/avif" srcset="hero.avif">
<source type="image/webp" srcset="hero.webp">
<img src="hero.jpg" alt="Hero image" width="1200" height="600">
</picture>
Size savings: AVIF ~50% smaller than JPEG, WebP ~30% smaller.
Responsive Images
<!-- Serve different sizes for different screens -->
<img
src="image-800.jpg"
srcset="image-400.jpg 400w,
image-800.jpg 800w,
image-1200.jpg 1200w"
sizes="(max-width: 600px) 400px,
(max-width: 1200px) 800px,
1200px"
alt="Description"
width="800"
height="600"
>
Lazy Loading
<!-- Native lazy loading โ supported in all modern browsers -->
<img src="below-fold.jpg" loading="lazy" alt="...">
<!-- Eager for above-the-fold images (LCP candidate) -->
<img src="hero.jpg" loading="eager" fetchpriority="high" alt="...">
Next.js Image Component
import Image from 'next/image';
// Automatically: WebP conversion, responsive sizes, lazy loading, blur placeholder
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority={true} // for LCP image โ don't lazy load
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
CSS Performance
Critical CSS
Inline the CSS needed for above-the-fold content, defer the rest:
<head>
<!-- Critical CSS inlined -->
<style>
/* Only styles needed for initial viewport */
body { margin: 0; font-family: sans-serif; }
.hero { height: 100vh; background: #000; }
</style>
<!-- Non-critical CSS loaded asynchronously -->
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>
</head>
Avoid Layout Shifts (CLS)
/* BAD: image without dimensions causes layout shift */
img { width: 100%; }
/* GOOD: reserve space with aspect-ratio */
img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
/* GOOD: explicit width and height (set in HTML too) */
img {
width: 800px;
height: 450px;
}
/* BAD: web font causes FOUT (Flash of Unstyled Text) */
@font-face {
font-family: 'MyFont';
src: url('font.woff2');
}
/* GOOD: font-display: swap shows fallback immediately */
@font-face {
font-family: 'MyFont';
src: url('font.woff2') format('woff2');
font-display: swap;
}
Caching
HTTP Cache Headers
# nginx: cache static assets for 1 year (content-hashed filenames)
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# HTML: no cache (always fresh)
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
}
Service Worker Cache
// sw.js: cache-first for static assets
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'script' ||
event.request.destination === 'style' ||
event.request.destination === 'image') {
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
}
});
Resource Hints
Tell the browser what to load next:
<!-- Preconnect: establish connection early (DNS + TCP + TLS) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://api.example.com">
<!-- Preload: fetch critical resources early -->
<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>
<link rel="preload" href="/hero.jpg" as="image">
<!-- Prefetch: fetch resources for next navigation -->
<link rel="prefetch" href="/dashboard.js">
<!-- DNS prefetch: resolve DNS early (cheaper than preconnect) -->
<link rel="dns-prefetch" href="https://analytics.example.com">
Performance Budget
Set limits and fail the build if exceeded:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
// Warn if any chunk exceeds 500KB
},
},
chunkSizeWarningLimit: 500, // KB
},
});
// .lighthouserc.json
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", {"minScore": 0.9}],
"first-contentful-paint": ["error", {"maxNumericValue": 2000}],
"largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
"cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}]
}
}
}
}
Quick Wins Checklist
- Enable gzip/brotli compression on the server
- Add
loading="lazy"to below-fold images - Add
widthandheightto all images - Use
deferon non-critical scripts - Preconnect to critical third-party origins
- Convert images to WebP/AVIF
- Set long cache headers for hashed assets
- Remove unused CSS (PurgeCSS)
- Split vendor bundle from app bundle
- Inline critical CSS
Resources
- web.dev: Core Web Vitals
- PageSpeed Insights
- WebPageTest
- Lighthouse CI
- Bundlephobia โ check package sizes
Comments