Progressive Web Apps (PWA)

Performance Optimization for PWAs

20 min Lesson 14 of 30

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

  1. Open Chrome DevTools (F12)
  2. Go to the "Lighthouse" tab
  3. Select categories to audit (Performance, PWA, etc.)
  4. Choose device type (Mobile or Desktop)
  5. 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:
  1. Run a Lighthouse audit on your PWA and identify performance issues
  2. Measure Core Web Vitals (LCP, FID/INP, CLS) in your app
  3. Implement lazy loading for all below-the-fold images
  4. Optimize at least 5 images using modern formats (WebP/AVIF)
  5. Add critical CSS inlining to your HTML
  6. Implement code splitting for at least 2 routes
  7. Add appropriate resource hints (preconnect, prefetch, preload)
  8. 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.