Web Performance
Advanced web performance optimization techniques and strategies
Web Performance
Web performance is critical for user experience, SEO, and business metrics. Every 100ms delay can reduce conversion by 7%.
Performance Metrics
Core Web Vitals
Largest Contentful Paint (LCP)
Target: < 2.5 seconds
Measures loading performance - when the largest content element becomes visible.
// Monitor LCP
import { onLCP } from 'web-vitals'
onLCP((metric) => {
console.log('LCP:', metric.value)
// Send to analytics
gtag('event', 'web_vitals', {
event_category: 'Web Vitals',
event_label: metric.id,
value: Math.round(metric.value),
metric_name: 'LCP'
})
})
Optimization:
// Preload LCP image
<link rel="preload" as="image" href="/hero.jpg" />
// Priority hint
<img src="/hero.jpg" fetchpriority="high" />
// Next.js Image with priority
<Image src="/hero.jpg" priority />
First Input Delay (FID)
Target: < 100 milliseconds
Measures interactivity - time from first user interaction to browser response.
import { onFID } from 'web-vitals'
onFID((metric) => {
console.log('FID:', metric.value)
})
Optimization:
// Code splitting
const HeavyComponent = lazy(() => import('./HeavyComponent'))
// Defer non-critical scripts
<script src="analytics.js" defer />
// Break up long tasks
async function processLargeDataset(data: any[]) {
for (let i = 0; i < data.length; i += 100) {
await scheduler.yield() // Yield to browser
processChunk(data.slice(i, i + 100))
}
}
Cumulative Layout Shift (CLS)
Target: < 0.1
Measures visual stability - unexpected layout shifts.
import { onCLS } from 'web-vitals'
onCLS((metric) => {
console.log('CLS:', metric.value)
})
Optimization:
// Always include dimensions
<img
src="/product.jpg"
width={400}
height={300}
alt="Product"
/>
// Reserve space for dynamic content
<div style={{ minHeight: '200px' }}>
{content || <Skeleton />}
</div>
// Font loading
<link
rel="preload"
href="/fonts/inter.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
Additional Metrics
Time to First Byte (TTFB)
import { onTTFB } from 'web-vitals'
onTTFB((metric) => {
console.log('TTFB:', metric.value)
})
// Optimize TTFB
// - Use CDN
// - Enable HTTP/2 or HTTP/3
// - Optimize server response time
// - Use edge caching
First Contentful Paint (FCP)
import { onFCP } from 'web-vitals'
onFCP((metric) => {
console.log('FCP:', metric.value)
})
Interaction to Next Paint (INP)
import { onINP } from 'web-vitals'
onINP((metric) => {
console.log('INP:', metric.value)
})
Critical Rendering Path
Optimization Steps
<!DOCTYPE html>
<html>
<head>
<!-- Critical CSS inline -->
<style>
/* Above-the-fold styles */
.header { /* ... */ }
.hero { /* ... */ }
</style>
<!-- Preconnect to external domains -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://api.example.com">
<!-- Preload critical resources -->
<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>
<link rel="preload" href="/hero.jpg" as="image">
<!-- DNS prefetch for later resources -->
<link rel="dns-prefetch" href="https://analytics.example.com">
</head>
<body>
<!-- Content -->
<!-- Load non-critical CSS -->
<link rel="stylesheet" href="/styles.css" media="print" onload="this.media='all'">
<!-- Defer JavaScript -->
<script src="/app.js" defer></script>
</body>
</html>
JavaScript Performance
Code Splitting
// Route-based splitting
const Dashboard = lazy(() => import('./Dashboard'))
const Settings = lazy(() => import('./Settings'))
function App() {
return (
<Suspense fallback={<Loader />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
)
}
// Component-based splitting
const Chart = lazy(() => import('./Chart'))
function Analytics() {
const [showChart, setShowChart] = useState(false)
return (
<div>
<button onClick={() => setShowChart(true)}>
Show Chart
</button>
{showChart && (
<Suspense fallback={<Spinner />}>
<Chart />
</Suspense>
)}
</div>
)
}
Tree Shaking
// ❌ Imports entire library
import _ from 'lodash'
// ✅ Import only what you need
import { debounce } from 'lodash-es'
// ✅ Or use individual packages
import debounce from 'lodash.debounce'
Bundle Analysis
# Next.js
ANALYZE=true npm run build
# Webpack
npm install -D webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
Image Optimization
Modern Formats
<picture>
<source srcSet="/hero.avif" type="image/avif" />
<source srcSet="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Hero image" />
</picture>
Responsive Images
<img
src="/small.jpg"
srcSet="
/small.jpg 400w,
/medium.jpg 800w,
/large.jpg 1200w
"
sizes="
(max-width: 400px) 100vw,
(max-width: 800px) 50vw,
33vw
"
alt="Responsive image"
/>
Lazy Loading
// Native lazy loading
<img src="/image.jpg" loading="lazy" alt="Lazy loaded" />
// Intersection Observer
function LazyImage({ src, alt }: { src: string; alt: string }) {
const [imageSrc, setImageSrc] = useState<string | null>(null)
const imgRef = useRef<HTMLImageElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setImageSrc(src)
observer.disconnect()
}
},
{ rootMargin: '50px' }
)
if (imgRef.current) {
observer.observe(imgRef.current)
}
return () => observer.disconnect()
}, [src])
return (
<img
ref={imgRef}
src={imageSrc || undefined}
alt={alt}
style={{ backgroundColor: '#f0f0f0' }}
/>
)
}
Image CDN
// Imgix, Cloudinary, or custom CDN
function optimizedImage(url: string, options: ImageOptions) {
const params = new URLSearchParams({
w: options.width.toString(),
h: options.height?.toString() || '',
q: options.quality?.toString() || '80',
fm: options.format || 'auto',
fit: options.fit || 'crop'
})
return `https://cdn.example.com/${url}?${params}`
}
// Usage
<img src={optimizedImage('hero.jpg', { width: 800, format: 'webp' })} />
Font Optimization
Font Loading Strategies
/* font-display values */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
/* Show fallback, swap when loaded */
font-display: swap;
/* OR: Block briefly, swap after */
font-display: block;
/* OR: Show fallback, optional font */
font-display: optional;
}
Variable Fonts
@font-face {
font-family: 'Inter Variable';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900; /* Full weight range */
font-display: swap;
}
.text {
font-family: 'Inter Variable';
font-weight: 450; /* Any value between 100-900 */
}
Subsetting
# Create subset with only Latin characters
pyftsubset font.ttf \
--output-file=font-subset.woff2 \
--flavor=woff2 \
--layout-features=* \
--unicodes=U+0000-00FF
Caching Strategies
HTTP Caching
// Next.js API Route
export async function GET() {
return new Response(JSON.stringify(data), {
headers: {
// Cache for 1 hour, revalidate in background
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
// Or: No cache
'Cache-Control': 'no-store',
// Or: Cache with must revalidate
'Cache-Control': 'public, max-age=3600, must-revalidate'
}
})
}
Service Worker Caching
// sw.js
const CACHE_NAME = 'v1'
const STATIC_ASSETS = [
'/',
'/styles.css',
'/app.js',
'/logo.svg'
]
// Install
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
)
})
// Fetch - Network falling back to cache
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then(response => {
const responseClone = response.clone()
caches.open(CACHE_NAME)
.then(cache => cache.put(event.request, responseClone))
return response
})
.catch(() => caches.match(event.request))
)
})
Resource Hints
<!-- Preconnect: Establish early connection -->
<link rel="preconnect" href="https://api.example.com">
<!-- DNS Prefetch: Resolve DNS early -->
<link rel="dns-prefetch" href="https://analytics.example.com">
<!-- Preload: Fetch critical resource -->
<link rel="preload" href="/hero.jpg" as="image">
<link rel="preload" href="/font.woff2" as="font" crossorigin>
<!-- Prefetch: Fetch for next navigation -->
<link rel="prefetch" href="/next-page.js">
<!-- Prerender: Render next page (use sparingly) -->
<link rel="prerender" href="/dashboard">
<!-- Module Preload: Preload ES modules -->
<link rel="modulepreload" href="/app.js">
React Performance
Memoization
// useMemo for expensive calculations
function ExpensiveList({ items, filter }: Props) {
const filteredItems = useMemo(() => {
console.log('Filtering items...')
return items.filter(item => item.category === filter)
}, [items, filter])
return <List items={filteredItems} />
}
// useCallback for function props
function Parent() {
const [count, setCount] = useState(0)
const handleClick = useCallback(() => {
console.log('Button clicked')
}, []) // No dependencies = never changes
return <ExpensiveChild onClick={handleClick} />
}
// React.memo for component memoization
const ExpensiveChild = memo(({ onClick }: Props) => {
console.log('Rendering ExpensiveChild')
return <button onClick={onClick}>Click me</button>
})
Virtual Scrolling
import { FixedSizeList } from 'react-window'
function VirtualList({ items }: { items: Item[] }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
)}
</FixedSizeList>
)
}
Concurrent Features
// useTransition for non-urgent updates
function SearchResults() {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
// Urgent: Update input
setQuery(e.target.value)
// Non-urgent: Update results
startTransition(() => {
setSearchResults(search(e.target.value))
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<Results />
</>
)
}
// useDeferredValue for deferred updates
function FilteredList({ items, filter }: Props) {
const deferredFilter = useDeferredValue(filter)
const filtered = useMemo(() => {
return items.filter(item => item.includes(deferredFilter))
}, [items, deferredFilter])
return <List items={filtered} />
}
Network Optimization
Request Optimization
// Parallel requests
const [users, posts, comments] = await Promise.all([
fetch('/api/users'),
fetch('/api/posts'),
fetch('/api/comments')
])
// Request deduplication
const cache = new Map()
async function fetchWithCache(url: string) {
if (cache.has(url)) {
return cache.get(url)
}
const promise = fetch(url).then(r => r.json())
cache.set(url, promise)
return promise
}
// Debounced requests
import { debounce } from 'lodash-es'
const searchDebounced = debounce(async (query: string) => {
const results = await fetch(`/api/search?q=${query}`)
// ...
}, 300)
Compression
// Enable compression in Next.js
// next.config.js
module.exports = {
compress: true, // Enables gzip
}
// Express
import compression from 'compression'
app.use(compression())
// Custom Brotli compression
import { brotliCompress } from 'zlib'
const compressed = await new Promise((resolve, reject) => {
brotliCompress(buffer, (err, result) => {
if (err) reject(err)
else resolve(result)
})
})
Performance Monitoring
Real User Monitoring (RUM)
// Custom performance tracking
class PerformanceTracker {
private metrics: Map<string, number> = new Map()
mark(name: string) {
performance.mark(name)
}
measure(name: string, startMark: string, endMark: string) {
performance.measure(name, startMark, endMark)
const measure = performance.getEntriesByName(name)[0]
this.metrics.set(name, measure.duration)
// Send to analytics
this.sendToAnalytics(name, measure.duration)
}
private sendToAnalytics(name: string, duration: number) {
navigator.sendBeacon('/analytics', JSON.stringify({
metric: name,
duration,
timestamp: Date.now()
}))
}
}
// Usage
const tracker = new PerformanceTracker()
tracker.mark('api-call-start')
await fetchData()
tracker.mark('api-call-end')
tracker.measure('api-call', 'api-call-start', 'api-call-end')
Performance Budget
// webpack.config.js
module.exports = {
performance: {
maxEntrypointSize: 250000, // 250kb
maxAssetSize: 100000, // 100kb
hints: 'error' // or 'warning'
}
}
Performance Testing
Lighthouse CI
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Lighthouse
uses: treosh/lighthouse-ci-action@v9
with:
urls: |
https://example.com
https://example.com/about
budgetPath: ./budget.json
uploadArtifacts: true
// budget.json
{
"budget": [
{
"path": "/*",
"timings": [
{
"metric": "interactive",
"budget": 3000
},
{
"metric": "first-contentful-paint",
"budget": 1000
}
],
"resourceSizes": [
{
"resourceType": "script",
"budget": 300
},
{
"resourceType": "image",
"budget": 500
}
]
}
]
}
Best Practices
Do
✅ Measure before optimizing ✅ Set performance budgets ✅ Monitor real user metrics ✅ Optimize images and fonts ✅ Use code splitting ✅ Implement caching strategies ✅ Minimize third-party scripts ✅ Use modern formats (AVIF, WebP)
Don't
❌ Premature optimization ❌ Ignore Core Web Vitals ❌ Load everything upfront ❌ Use large frameworks unnecessarily ❌ Block the main thread ❌ Skip compression ❌ Forget mobile performance ❌ Ignore analytics
Tools
- Lighthouse: Performance auditing
- WebPageTest: Detailed performance testing
- Chrome DevTools: Profiling and debugging
- Bundle Analyzers: Webpack, Next.js
- Web Vitals: Google's metrics library
- SpeedCurve: Performance monitoring
- Calibre: Performance tracking