# Performance

> Measure LCP, INP, and CLS in the field, reproduce the slow page in Lighthouse, fix each metric, precache the shell, and guard the result with Lighthouse CI.

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](/reference/performance/core-web-vitals/);
this guide is the procedure for moving them.

## 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.

```js
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

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.

:::observed
A Chrome DevTools Lighthouse mobile report lists **Largest Contentful Paint** under
Performance and a **Largest Contentful Paint element** diagnostic that names the DOM node
and splits its time into TTFB, load delay, load time, and render delay. The LCP score bands
documented for that audit are 0 to 2.5 s (green), 2.5 to 4 s (orange), and over 4 s (red)
on mobile, and 0 to 1.2 s, 1.2 to 2.4 s, and over 2.4 s on desktop.
:::

## 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.

```html
<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

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.

```js
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

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

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](/guides/offline/)
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

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.

```js
// 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.

## See also

- [Core Web Vitals for PWAs: LCP, INP, and CLS](/reference/performance/core-web-vitals/)
- [Startup performance: paint and navigation timings](/reference/performance/startup-performance/)
- [Offline strategies](/guides/offline/)
- [Web Vitals](https://web.dev/articles/vitals) (web.dev)
- [Interaction to Next Paint (INP)](https://web.dev/articles/inp) (web.dev)
- [web-vitals](https://github.com/GoogleChrome/web-vitals) (github.com)

← Back to the [Guides](/guides/) overview.