Introduction to PWA Performance
Performance is critical for Progressive Web Apps. Users expect fast, responsive experiences, especially on mobile devices. Poor performance leads to higher bounce rates and lower user engagement.
Performance Statistics:
- 53% of mobile users abandon sites that take over 3 seconds to load
- A 1-second delay in page load can result in a 7% reduction in conversions
- Google uses performance metrics as ranking factors in search results
Lighthouse Audits
Lighthouse is an automated tool for measuring PWA performance, accessibility, SEO, and best practices:
Running Lighthouse
# Install Lighthouse CLI
npm install -g lighthouse
# Run audit
lighthouse https://your-pwa.com --view
# Run audit with specific categories
lighthouse https://your-pwa.com --only-categories=performance,pwa --view
# Generate JSON report
lighthouse https://your-pwa.com --output=json --output-path=./report.json
# Run in headless mode
lighthouse https://your-pwa.com --chrome-flags="--headless"
Lighthouse in Chrome DevTools
- Open Chrome DevTools (F12)
- Go to the "Lighthouse" tab
- Select categories to audit (Performance, PWA, etc.)
- Choose device type (Mobile or Desktop)
- Click "Generate report"
Tip: Always test on mobile with throttling enabled. Desktop performance doesn't reflect real-world mobile experience. Use "Slow 4G" throttling for realistic results.
Core Web Vitals
Core Web Vitals are Google's metrics for measuring real-world user experience:
1. Largest Contentful Paint (LCP)
Measures loading performance. LCP should occur within 2.5 seconds of page load.
// Measure LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.renderTime || lastEntry.loadTime);
// Send to analytics
sendToAnalytics({
metric: 'LCP',
value: lastEntry.renderTime || lastEntry.loadTime,
rating: lastEntry.renderTime < 2500 ? 'good' : 'needs-improvement'
});
}).observe({ entryTypes: ['largest-contentful-paint'] });
Improving LCP:
- Optimize and compress images
- Preload critical resources
- Remove render-blocking JavaScript and CSS
- Use server-side rendering or static generation
- Implement efficient caching strategies
2. First Input Delay (FID) / Interaction to Next Paint (INP)
Measures interactivity. FID should be less than 100ms, INP less than 200ms.
// Measure FID
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
const delay = entry.processingStart - entry.startTime;
console.log('FID:', delay);
sendToAnalytics({
metric: 'FID',
value: delay,
rating: delay < 100 ? 'good' : 'needs-improvement'
});
});
}).observe({ entryTypes: ['first-input'] });
Improving FID/INP:
- Break up long JavaScript tasks
- Use web workers for heavy computations
- Optimize event handlers
- Defer non-critical JavaScript
- Use code splitting to reduce bundle size
3. Cumulative Layout Shift (CLS)
Measures visual stability. CLS should be less than 0.1.
// Measure CLS
let clsValue = 0;
let sessionValue = 0;
let sessionEntries = [];
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (!entry.hadRecentInput) {
sessionValue += entry.value;
sessionEntries.push(entry);
if (sessionValue > clsValue) {
clsValue = sessionValue;
console.log('CLS:', clsValue);
sendToAnalytics({
metric: 'CLS',
value: clsValue,
rating: clsValue < 0.1 ? 'good' : 'needs-improvement'
});
}
}
});
}).observe({ entryTypes: ['layout-shift'] });
Improving CLS:
- Always include size attributes on images and videos
- Reserve space for ads and embeds
- Avoid inserting content above existing content
- Use CSS aspect-ratio for media
- Preload fonts to prevent FOIT/FOUT
Lazy Loading
Defer loading of non-critical resources until they're needed:
Native Image Lazy Loading
<!-- Browser automatically lazy loads images below the fold -->
<img src="image.jpg" alt="Description" loading="lazy">
<!-- Load immediately (for above-the-fold images) -->
<img src="hero.jpg" alt="Hero" loading="eager">
<!-- Also works with iframes -->
<iframe src="video.html" loading="lazy"></iframe>
Lazy Loading with Intersection Observer
// Lazy load images with fallback for older browsers
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.add('loaded');
observer.unobserve(img);
}
});
}, {
rootMargin: '50px' // Start loading 50px before entering viewport
});
// Observe all images with data-src attribute
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
<!-- HTML markup -->
<img data-src="large-image.jpg"
src="placeholder.jpg"
alt="Description"
class="lazy">
Lazy Loading JavaScript Modules
// Dynamic import - loads module only when needed
async function loadFeature() {
const module = await import('./feature.js');
module.initialize();
}
// Load on button click
document.getElementById('load-btn').addEventListener('click', loadFeature);
// Load when element enters viewport
const featureObserver = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting) {
const { default: initFeature } = await import('./feature.js');
initFeature();
featureObserver.disconnect();
}
});
featureObserver.observe(document.getElementById('feature-section'));
Code Splitting
Break your JavaScript bundle into smaller chunks to reduce initial load time:
Route-Based Code Splitting
// Using React (example)
import { lazy, Suspense } from 'react';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</Suspense>
);
}
Webpack Code Splitting
// webpack.config.js
module.exports = {
entry: './src/index.js',
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].js'
},
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10
},
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true
}
}
}
}
};
Image Optimization
Modern Image Formats
<picture>
<!-- WebP for browsers that support it -->
<source srcset="image.webp" type="image/webp">
<!-- AVIF for best compression -->
<source srcset="image.avif" type="image/avif">
<!-- Fallback to JPEG -->
<img src="image.jpg" alt="Description" loading="lazy">
</picture>
Image Optimization Tools
# Install imagemin
npm install imagemin imagemin-mozjpeg imagemin-pngquant imagemin-svgo imagemin-webp
# optimize-images.js
const imagemin = require('imagemin');
const imageminMozjpeg = require('imagemin-mozjpeg');
const imageminPngquant = require('imagemin-pngquant');
const imageminWebp = require('imagemin-webp');
(async () => {
await imagemin(['images/*.{jpg,png}'], {
destination: 'images/optimized',
plugins: [
imageminMozjpeg({ quality: 80 }),
imageminPngquant({ quality: [0.6, 0.8] }),
imageminWebp({ quality: 80 })
]
});
})();
Responsive Images
<img src="small.jpg"
srcset="small.jpg 400w,
medium.jpg 800w,
large.jpg 1200w"
sizes="(max-width: 600px) 100vw,
(max-width: 1200px) 50vw,
33vw"
alt="Responsive image"
loading="lazy">
Font Optimization
Font Loading Strategies
/* 1. Font Display */
@font-face {
font-family: 'CustomFont';
src: url('font.woff2') format('woff2');
font-display: swap; /* Show fallback immediately, swap when loaded */
/* Other options: auto, block, fallback, optional */
}
/* 2. Preload critical fonts */
<link rel="preload"
href="font.woff2"
as="font"
type="font/woff2"
crossorigin>
/* 3. Use system fonts for instant rendering */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
Font Subsetting
# Install glyphhanger
npm install -g glyphhanger
# Generate subset with only used characters
glyphhanger --subset=font.ttf --formats=woff2
# Subset for specific text
glyphhanger --subset=font.ttf --whitelist="ABCDEFGabcdefg0123456789"
Critical CSS
Inline critical CSS and defer non-critical styles:
<!DOCTYPE html>
<html>
<head>
<!-- Inline critical CSS -->
<style>
/* Above-the-fold styles only */
body { margin: 0; font-family: sans-serif; }
.header { background: #333; color: white; padding: 1rem; }
.hero { min-height: 100vh; }
</style>
<!-- Preload full CSS -->
<link rel="preload" href="styles.css" as="style">
<!-- Load full CSS asynchronously -->
<link rel="stylesheet" href="styles.css" media="print" onload="this.media='all'">
<!-- Fallback for browsers without JS -->
<noscript>
<link rel="stylesheet" href="styles.css">
</noscript>
</head>
<body>
<!-- Content -->
</body>
</html>
Generate Critical CSS Automatically
# Install critical
npm install critical
# generate-critical.js
const critical = require('critical');
critical.generate({
inline: true,
base: 'dist/',
src: 'index.html',
target: 'index.html',
width: 1300,
height: 900,
minify: true
});
Resource Hints
DNS Prefetch
<!-- Resolve DNS early for external domains -->
<link rel="dns-prefetch" href="//fonts.googleapis.com">
<link rel="dns-prefetch" href="//api.example.com">
Preconnect
<!-- Establish early connection to critical origins -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://cdn.example.com">
Prefetch
<!-- Fetch resources likely needed for next navigation -->
<link rel="prefetch" href="/about.html">
<link rel="prefetch" href="/styles/about.css">
Preload
<!-- High-priority resources needed for current page -->
<link rel="preload" href="hero.jpg" as="image">
<link rel="preload" href="critical.css" as="style">
<link rel="preload" href="app.js" as="script">
<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
Service Worker Performance
// Efficient service worker patterns
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') return;
// Skip Chrome extensions and other schemes
if (!event.request.url.startsWith('http')) return;
// Use different strategies based on request type
if (event.request.destination === 'image') {
event.respondWith(cacheFirst(event.request));
} else if (event.request.destination === 'document') {
event.respondWith(networkFirst(event.request));
}
});
// Efficient cache-first strategy
async function cacheFirst(request) {
const cache = await caches.open('v1');
const cached = await cache.match(request);
return cached || fetch(request);
}
Exercise:
- Run a Lighthouse audit on your PWA and identify performance issues
- Measure Core Web Vitals (LCP, FID/INP, CLS) in your app
- Implement lazy loading for all below-the-fold images
- Optimize at least 5 images using modern formats (WebP/AVIF)
- Add critical CSS inlining to your HTML
- Implement code splitting for at least 2 routes
- Add appropriate resource hints (preconnect, prefetch, preload)
- Re-run Lighthouse and aim for a score above 90
Warning: Don't over-optimize. Focus on the metrics that matter most to your users. Premature optimization can lead to complex code that's hard to maintain. Always measure before and after optimization to ensure improvements.