Skip to content

Performance

Published

At the end of this guide your PWA reports field values at or under the Core Web Vitals thresholds (LCP 2.5 s, INP 200 ms, CLS 0.1, each at the 75th percentile of page loads), repeat launches paint from a cached shell, and a CI step fails the build when a metric regresses. Performance work is a loop: measure, fix the largest offender, measure again.

You need the production build served over HTTPS, a mid-range phone or Chrome’s CPU and network throttling, and a place to send telemetry. The metric definitions live in Core Web Vitals for PWAs: LCP, INP, and CLS; this guide is the procedure for moving them.

Lab tools cannot see what real users experience on their devices, and the Chrome User Experience Report gives origin-level numbers without per-page diagnostics, so instrument the page. The web-vitals library (Apache 2.0) wraps the underlying browser APIs and reports each metric when it is final; onCLS and onINP can fire more than once, for example when the page is hidden.

import { onCLS, onINP, onLCP } from 'web-vitals/attribution';
function report(metric) {
const body = JSON.stringify({
name: metric.name, // 'LCP' | 'INP' | 'CLS'
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
id: metric.id,
target: metric.attribution?.element ?? metric.attribution?.interactionTarget ?? null,
path: location.pathname,
});
(navigator.sendBeacon && navigator.sendBeacon('/vitals', body)) ||
fetch('/vitals', { body, method: 'POST', keepalive: true });
}
onCLS(report);
onINP(report);
onLCP(report);

The attribution build adds about 1.5 KB (Brotli) over the standard build and tells you which element was the LCP candidate or which interaction was the slowest, which is the difference between a number and a fix. Aggregate per path, then read the 75th percentile, split by mobile and desktop.

Open Chrome DevTools, choose the Lighthouse panel, and run a mobile audit against the production URL that your field data flags. Lighthouse measures LCP and CLS but not INP: a lab run loads the page without interacting, so Total Blocking Time stands in as a proxy. Lighthouse also scores desktop more strictly than the field thresholds (LCP under 1.2 s is green on desktop against 2.5 s on mobile), so compare lab numbers with lab numbers.

The LCP element is usually a hero image or a text block waiting on a web font. Give the image an explicit fetchpriority="high" and a <link rel="preload">, serve it as AVIF or WebP at the rendered size, and remove render-blocking stylesheets and scripts from the path to it. Fonts: font-display: swap paints text in the fallback face at once at the cost of a visible swap; optional avoids the swap at the cost of sometimes not showing the web font.

<link rel="preload" as="image" href="/hero-1200.avif" imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w" imagesizes="100vw">
<img src="/hero-1200.avif" srcset="/hero-800.avif 800w, /hero-1200.avif 1200w" sizes="100vw" width="1200" height="600" fetchpriority="high" alt="">

INP is the latency of the slowest click, tap, or key press over the page’s life (one outlier per 50 interactions discarded), measured from input to the next frame. Long tasks on the main thread are the usual cause. Split work with scheduler.yield() where it exists and setTimeout(resolve, 0) where it does not, defer non-critical JavaScript, and keep the handler’s own work small so the next paint can happen before the expensive part runs.

async function onFilterChange(event) {
renderSpinner(); // cheap: paint feedback first
await yieldToMain();
applyFilter(event.target.value); // expensive: runs after the frame
}
function yieldToMain() {
if ('scheduler' in globalThis && 'yield' in scheduler) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}

Reserve space for everything that arrives late: width and height attributes on images and videos, aspect-ratio on embeds, a fixed-height container for ads and consent banners, and min-height on content that streams in. Animate with transform rather than top/left, which do not count as layout shifts.

A PWA’s structural advantage is the installed launch: a shell precached by the service worker paints without a network round trip. Follow Offline strategies to precache HTML, CSS, and JavaScript, then measure LCP on the second launch separately from the first visit; the first visit still pays full price.

Add a budget so a regression fails the build rather than the next field report. Lighthouse CI runs Lighthouse against a static build or a started server and asserts on audits.

lighthouserc.js
module.exports = {
ci: {
collect: { staticDistDir: './dist' },
assert: {
preset: 'lighthouse:recommended',
assertions: {
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
},
},
upload: { target: 'temporary-public-storage' },
},
};

Install with npm install -g @lhci/cli and run lhci autorun in CI. The temporary public storage target is the simplest upload; reports there are public and expire, so switch to an LHCI server for private projects. When the run passes, the field numbers from step 1 confirm the change for real users within a few days of traffic.

← Back to the Guides overview.