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.
Collect field data with web-vitals
Section titled “Collect field data with web-vitals”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.
Reproduce the slow page in Lighthouse
Section titled “Reproduce the slow page in Lighthouse”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.
Shrink LCP
Section titled “Shrink LCP”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="">Shrink INP
Section titled “Shrink INP”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));}Shrink CLS
Section titled “Shrink CLS”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.
Precache the shell for repeat launches
Section titled “Precache the shell for repeat launches”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.
Guard the gain with Lighthouse CI
Section titled “Guard the gain with Lighthouse CI”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.
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.
See also
Section titled “See also”- Core Web Vitals for PWAs: LCP, INP, and CLS
- Startup performance: paint and navigation timings
- Offline strategies
- Web Vitals (web.dev)
- Interaction to Next Paint (INP) (web.dev)
- web-vitals (github.com)
← Back to the Guides overview.