Skip to content

Measuring Performance

Measuring a PWA's performance means answering two different questions with two different kinds of tools: lab tools (Lighthouse, the DevTools Performance panel, WebPageTest) reproduce a load under controlled conditions so you can debug and prevent regressions, and field data (the Chrome UX Report and your own real-user monitoring) tells you what users actually experience. A service worker complicates both: by default the lab measures a first visit with no worker at all, while most of your field traffic may be repeat visits served from Cache Storage by a worker that is sometimes cold and sometimes warm. This page shows how to set up each tool so it measures the scenario you care about, how to measure the service worker's own contribution, how to track cache hit rates, and how to enforce budgets in CI.

Key takeaways

  • Lighthouse clears service workers and Cache Storage before every navigation audit by default, so a standard run always measures a first visit. Use --disable-storage-reset, or a Lighthouse user flow (whose second and later navigations keep storage by default), to measure repeat visits.
  • Measure the worker in three states: no worker (first visit), cold worker (installed but stopped) and warm worker. Chrome's DevTools Protocol can stop all workers (ServiceWorker.stopAllWorkers) between runs.
  • In the field, segment every metric by whether the page was controlled by a service worker, display mode (browser tab or installed app), and navigation type. Without those dimensions, the numbers average away the effects of your PWA features.
  • Navigation Timing's workerStart, fetchStart, transferSize and deliveryType (Chromium, and Safari since 26.4) let you classify every navigation and subresource as worker cache hit, worker network fetch, HTTP cache hit or network. A service worker can also label its responses with a Server-Timing header, which pages can read in all three engines.
  • Measure cache hit rates inside the service worker (the only place that knows why something missed) and report them in batches; cross-check with Resource Timing on the page.
  • Enforce budgets with Lighthouse CI assertions on metrics and resource sizes. Lighthouse 12 removed budget.json support from Lighthouse itself; @lhci/cli 0.15.1 bundles Lighthouse 12.6.1, so its audit IDs are the pre-13 names.

Lab and field: what each tool can tell you

Tool Type Measures Service worker state Best for
Lighthouse (DevTools, CLI, Node) Lab, simulated or applied throttling FCP, LCP, TBT, CLS, Speed Index; INP only in timespan mode Cleared by default Regression checks, diagnostics (insights)
Chrome DevTools Performance panel Lab, your machine Everything on the main thread, network, workers, frames Whatever your profile has Root-causing a specific slow load or interaction
WebPageTest Lab, real devices and networks Full waterfall, filmstrip, Core Web Vitals, first and repeat view First view: none; repeat view: installed during first view Realistic network conditions, protocol-level detail
PageSpeed Insights Field (CrUX) + lab (Lighthouse) CrUX p75 for page and origin, one Lighthouse run Lab part: cleared Quick public check, SEO-relevant field status
CrUX API / History API / BigQuery Field p75 and histograms of LCP, INP, CLS, FCP, TTFB, and navigation types, by form factor All states mixed, no SW dimension Origin-level trends and competitive comparison
Your RUM (web-vitals, PerformanceObserver) Field Anything the browser exposes, with your own dimensions Known per page load Understanding why, segmented by your users and features

The rest of this page goes through them in that order. For the definitions of the metrics themselves (LCP, INP, CLS, their thresholds and subparts), see Core Web Vitals.

Lighthouse in the lab

Lighthouse loads a page in Chrome, records a trace and network log, and computes metrics and audits. The current release is Lighthouse 13.5.0 (released September 17, 2026, and expected in the DevTools of Chrome 156), which requires Node.js 22.19 or later. Lighthouse 13 completed the move from the old performance audits to insight audits shared with the DevTools Performance panel (for example render-blocking-insight, lcp-discovery-insight, lcp-breakdown-insight, cache-insight, document-latency-insight, image-delivery-insight, forced-reflow-insight, network-dependency-tree-insight). The PWA category was removed in Lighthouse 12; see Lighthouse & Auditing for auditing installability and offline behavior without it.

How the performance score is computed

The performance score is a weighted average of five lab metrics, each first mapped to a 0–100 score on a log-normal curve derived from HTTP Archive data. The weights in Lighthouse 13's default configuration are:

Metric Weight
Total Blocking Time (TBT) 30%
Largest Contentful Paint (LCP) 25%
Cumulative Layout Shift (CLS) 25%
First Contentful Paint (FCP) 10%
Speed Index (SI) 10%

INP is not part of the score: a navigation audit has no user interactions. TBT is its lab proxy. Insight audits have weight 0: they diagnose, they do not score.

Throttling and variability

By default Lighthouse uses simulated throttling (throttlingMethod: "simulate"): it loads the page unthrottled, then uses a model of the page's dependency graph (Lantern) to estimate how it would have loaded on a "Slow 4G" connection, 150 ms round-trip time, 1.6 Mbps down and 750 Kbps up, with a 4× CPU slowdown. The alternatives are devtools (request-level throttling applied during the load) and provided (no throttling; use when you throttle at the network level yourself).

Simulation is fast and reduces variance, but it models the network, not your service worker. A page served entirely from Cache Storage has no network requests to simulate, and the worker's own start-up and processing time are measured as they happened on the host machine. For warm-worker runs, prefer --throttling-method=devtools or provided with real network shaping, and state which you used when comparing numbers.

Lighthouse results vary from run to run (CPU contention, network jitter, third-party responses). Run at least three times per configuration and use the median, which is what Lighthouse CI does by default.

The storage reset problem

Before each navigation audit Lighthouse clears storage for the origin. In Lighthouse 13.5.0 the default clearStorageTypes are file_systems, shader_cache, service_workers and cache_storage, and disableStorageReset defaults to false. In other words, a default run unregisters your service worker and empties its caches, and measures the first visit. That is a legitimate scenario (it is what every new user gets), but it says nothing about the repeat visits that make up most traffic for an installed PWA.

To measure repeat visits you need to load the page once (so the worker installs and precaches), then audit without the reset:

Terminal
# 1. Start a Chrome instance you control, with a dedicated profile and remote
#    debugging (the binary name differs by OS: google-chrome, chrome.exe,
#    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome").
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/lh-profile &

# 2. First-visit run: storage is cleared, then the page loads and the service
#    worker installs and precaches in that profile.
npx lighthouse https://app.example.com/ --port=9222 --only-categories=performance \
  --output=json --output-path=./cold.json

# 3. Repeat-visit run against the same browser, without clearing storage.
npx lighthouse https://app.example.com/ --port=9222 --only-categories=performance \
  --disable-storage-reset --output=json --output-path=./warm.json

A cleaner and more controllable way is a user flow, driven by Puppeteer. Lighthouse's user-flow implementation disables the storage reset automatically for every navigation after the first one, so a flow of two navigations measures "first visit, then repeat visit" out of the box:

scripts/lh-flow.mjs
// Measures three PWA scenarios in one flow:
//   1. first visit (no service worker),
//   2. repeat visit with a cold (stopped) service worker,
//   3. repeat visit with a warm (running) service worker.
// Requires: npm i -D lighthouse puppeteer   (Lighthouse 13 needs Node 22.19+)
import { writeFile } from "node:fs/promises";
import puppeteer from "puppeteer";
import { startFlow } from "lighthouse";

const URL_UNDER_TEST = process.argv[2] ?? "https://app.example.com/";

const browser = await puppeteer.launch({ headless: true });
try {
  const page = await browser.newPage();
  const cdp = await page.createCDPSession();
  await cdp.send("ServiceWorker.enable");

  const flow = await startFlow(page, { name: "PWA load scenarios" });

  // Step 1: storage is reset for the first navigation of a flow.
  await flow.navigate(URL_UNDER_TEST, { name: "First visit (no service worker)" });

  // Let the worker finish installing and precaching before the next step.
  await page.evaluate(async () => {
    const registration = await navigator.serviceWorker?.ready;
    // Give the install handler's precache a moment to complete if it is large.
    await new Promise((resolve) => setTimeout(resolve, 3000));
    return Boolean(registration?.active);
  });

  // Step 2: stop the worker so the navigation pays start-up cost.
  await cdp.send("ServiceWorker.stopAllWorkers");
  await flow.navigate(URL_UNDER_TEST, { name: "Repeat visit (cold worker)" });

  // Step 3: the worker is still running from step 2.
  await flow.navigate(URL_UNDER_TEST, { name: "Repeat visit (warm worker)" });

  await writeFile("pwa-flow.html", await flow.generateReport());
  await writeFile("pwa-flow.json", JSON.stringify(await flow.createFlowResult(), null, 2));
  console.log("Wrote pwa-flow.html and pwa-flow.json");
} finally {
  await browser.close();
}

ServiceWorker.stopAllWorkers is a DevTools Protocol command; it is the programmatic equivalent of clicking Stop in DevTools' Application panel. After it, the next navigation must start the worker, which is the state an installed PWA is in after it has been idle for a while (Chromium and Firefox stop idle workers after about 30 seconds). Flows also support startTimespan()/endTimespan() to measure interactions (TBT, CLS and INP for scripted clicks) and snapshot() to audit a page state; see the user flows documentation.

When you compare the three steps, look at TTFB (the first-byte time of the document, which includes worker start-up), FCP and LCP. A large gap between the cold and warm steps is the signal to use navigation preload or static routing.

The DevTools Performance panel

The Performance panel in Chrome DevTools is where you go once a metric tells you that something is slow and you need to know why.

Live metrics. When you open the panel, it shows LCP, CLS and INP for the current page as you interact with it, measured locally. It can also fetch CrUX field data for the URL and origin and show it next to your local values, which is a quick way to see whether your machine is representative. The panel offers calibrated CPU throttling presets for low-tier and mid-tier mobile devices, network throttling presets such as "Fast 4G", and a Disable network cache option.

Recordings. Record a page load (the reload button) or an interaction. The Main track shows tasks, with long tasks marked; the Network track shows requests with their priority and whether they were render-blocking; the Interactions track shows each interaction broken into input delay, processing and presentation delay; the Layout shifts track shows each shift and its culprit; Workers get their own tracks. The Insights sidebar lists the same insights Lighthouse 13 uses, tied to the trace.

Service workers in a trace. A controlled navigation shows the worker's thread as a separate track: you can see worker start-up (script evaluation) and the fetch event handler running before the document request completes. In the Network panel, responses from the worker show "(ServiceWorker)" in the Size column, and requests the worker itself made are marked with a gear icon. Keep the Application panel's service worker options in mind while profiling: Update on reload and Bypass for network change what you are measuring.

Custom tracks for your own code. The DevTools extensibility API lets you add your own tracks to recordings through performance.measure() and performance.mark() with a detail.devtools payload, which is invaluable for showing app-level phases (boot, hydration, sync) alongside the browser's work:

src/perf-tracks.js
// Adds app-level phases to the DevTools Performance panel as a custom track.
// Browsers without the extension simply record a normal User Timing measure.
export async function measured(name, fn, { track = "App", trackGroup = "PWA", color = "primary" } = {}) {
  const start = performance.now();
  try {
    return await fn();
  } finally {
    performance.measure(name, {
      start,
      end: performance.now(),
      detail: {
        devtools: {
          dataType: "track-entry",
          track,
          trackGroup,
          color, // "primary", "secondary", "tertiary" (with -light / -dark variants) or "error"
          tooltipText: name,
        },
      },
    });
  }
}

// Usage:
// await measured("Hydrate shell", () => hydrate(root), { track: "Boot" });
// await measured("Open IndexedDB", () => openDb(), { track: "Data" });

The same User Timing measures are available to your RUM code with PerformanceObserver (type: "measure"), so one instrumentation serves both lab profiling and field reporting.

WebPageTest

WebPageTest runs tests on real browsers on real (or realistically shaped) networks in locations around the world, and produces a detailed waterfall with connection reuse, protocol, priority and timing for every request, a filmstrip and video, and Core Web Vitals. Two features make it especially useful for PWAs:

  • First View and Repeat View. With repeat view enabled, WebPageTest loads the page a second time in the same browser profile after the first view, so the service worker installed during the first view can control the repeat view. Confirm it in the repeat view's waterfall (worker-served responses have no connection, DNS or TLS time); if the worker had not finished installing, the repeat view is just an HTTP-cache warm load.
  • Scripting. Multi-step scripts let you control exactly what state the browser is in. A script that warms the worker, then measures only the second navigation:
webpagetest-script.txt
// Step 1: first visit, not recorded. Lets the service worker install and precache.
logData 0
navigate https://app.example.com/
// Pause (in seconds) so the install handler can finish precaching.
sleep 5
// Step 2: the measured navigation, controlled by the worker.
logData 1
navigate https://app.example.com/products/

Other useful commands include block and blockDomains (measure without a third party), setHeader (for example to add a header your server uses to switch features), setCookie, setDns and combineSteps. WebPageTest also supports Lighthouse runs and custom metrics (JavaScript snippets evaluated at the end of the test), which you can use to extract navigator.serviceWorker.controller !== null or Navigation Timing fields per run.

Field data: CrUX and PageSpeed Insights

The Chrome UX Report (CrUX) aggregates Core Web Vitals from opted-in Chrome users. Its coverage has important gaps for PWAs, including no iOS users at all, no data for non-indexable or low-traffic pages, and no soft navigations; Core Web Vitals covers them in detail. Within those limits, CrUX is the authoritative "scoreboard", and there are three ways to query it.

PageSpeed Insights and its API

PageSpeed Insights shows CrUX field data for the URL and the origin (when available) and runs a single Lighthouse audit. The PSI API (https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=…&strategy=mobile&category=performance&key=…) returns the same data: loadingExperience (URL-level CrUX), originLoadingExperience (origin-level) and lighthouseResult. Field metrics appear under keys such as LARGEST_CONTENTFUL_PAINT_MS, INTERACTION_TO_NEXT_PAINT and CUMULATIVE_LAYOUT_SHIFT_SCORE, each with a percentile (p75) and a category; note that PSI reports the CLS percentile multiplied by 100 (a value of 8 means 0.08).

The CrUX API

The CrUX API returns p75 values and histograms for the most recent 28-day window, updated daily around 04:00 UTC, with a quota of 150 queries per minute per Google Cloud project. It accepts an origin or a url, an optional formFactor (PHONE, TABLET, DESKTOP) and an optional list of metrics, which include largest_contentful_paint, interaction_to_next_paint, cumulative_layout_shift, first_contentful_paint, experimental_time_to_first_byte, navigation_types, round_trip_time, form_factors, largest_contentful_paint_resource_type and the LCP image subparts.

The CrUX History API (records:queryHistoryRecord) returns a time series of up to 40 weekly collection periods (25 by default), each a 28-day window; it is updated every Monday with data up to the previous Saturday. It is the right tool for spotting regressions after a deployment and for showing the long-term effect of adding a service worker:

scripts/crux-history.mjs
// Prints the weekly p75 LCP and INP trend for an origin on phones.
// Usage: CRUX_API_KEY=... node scripts/crux-history.mjs https://app.example.com
const origin = process.argv[2];
const key = process.env.CRUX_API_KEY;
if (!origin || !key) {
  console.error("Usage: CRUX_API_KEY=... node crux-history.mjs <origin>");
  process.exit(1);
}

const response = await fetch(
  `https://chromeuxreport.googleapis.com/v1/records:queryHistoryRecord?key=${key}`,
  {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      origin,
      formFactor: "PHONE",
      metrics: ["largest_contentful_paint", "interaction_to_next_paint", "navigation_types"],
      collectionPeriodCount: 40,
    }),
  },
);

if (response.status === 404) {
  console.error("No CrUX data for this origin (too little traffic, or not public).");
  process.exit(2);
}
if (!response.ok) {
  console.error(`CrUX API error ${response.status}: ${await response.text()}`);
  process.exit(1);
}

const { record } = await response.json();
const periods = record.collectionPeriods.map((p) => {
  const { year, month, day } = p.lastDate;
  return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
});
const lcp = record.metrics.largest_contentful_paint?.percentilesTimeseries.p75s ?? [];
const inp = record.metrics.interaction_to_next_paint?.percentilesTimeseries.p75s ?? [];

console.table(periods.map((date, i) => ({ periodEnding: date, lcpP75: lcp[i] ?? "n/a", inpP75: inp[i] ?? "n/a" })));

Periods with too little data come back as null (NaN in some fields), so handle gaps. The navigation_types fractions (navigate, reload, back_forward, back_forward_cache, prerender, restore, and others) show how much of your traffic is back/forward-cache restores or prerenders, which helps when interpreting changes in LCP.

BigQuery. The CrUX dataset on BigQuery is published monthly and adds dimensions such as country and effective connection type at the origin level. Use it for large-scale analyses; for a single PWA, the History API is usually enough.

Real-user monitoring (RUM)

CrUX tells you whether you have a problem. Only your own RUM can tell you for whom and why: whether slow LCPs come from cold worker starts, first visits, a specific route, installed users on a particular device class, or iOS (which CrUX does not see at all).

PerformanceObserver fundamentals

Every browser performance API delivers entries through PerformanceObserver:

src/rum/observe.js
/**
 * Observes one entry type, including entries recorded before this code ran.
 * Returns a function that stops observing.
 */
export function observe(type, callback, options = {}) {
  if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return () => {};
  const observer = new PerformanceObserver((list, _observer, meta) => {
    // droppedEntriesCount is only provided on the first buffered callback,
    // when the browser's buffer overflowed before you started observing.
    if (meta?.droppedEntriesCount) {
      console.warn(`${meta.droppedEntriesCount} ${type} entries were dropped`);
    }
    callback(list.getEntries());
  });
  try {
    observer.observe({ type, buffered: true, ...options });
  } catch {
    return () => {};
  }
  return () => observer.disconnect();
}

Things to know:

  • buffered: true delivers entries recorded before you called observe(), so RUM code can load late (after the page's own critical resources) without losing data. Buffers are limited: Resource Timing keeps 250 entries by default (raise it with performance.setResourceTimingBufferSize() and watch for resourcetimingbufferfull), and other types have their own limits.
  • Use type, not entryTypes, when you need buffered; the older entryTypes: [...] form does not support it.
  • Feature-detect with PerformanceObserver.supportedEntryTypes. Chromium supports the most types; layout-shift, long-animation-frame, longtask, element and (since Chrome 151) soft-navigation are Chromium-only, while largest-contentful-paint and event (the basis of INP) have been available in all three engines since Safari 26.2.
  • The web-vitals library (6.2.2 as of September 2026) wraps these observers and implements the exact metric definitions, including bfcache restores, prerendering and soft navigations. Use it for LCP, INP, CLS, FCP and TTFB rather than re-implementing them; Core Web Vitals documents its API and a complete RUM module. Use raw observers for everything else on this page.

Getting data out reliably

Send data when the page becomes hidden, not on unload: on mobile, users switch apps and the page may never unload normally, and unload handlers also make pages ineligible for the back/forward cache.

  • navigator.sendBeacon(url, data) queues a POST that survives page teardown. The total size of queued beacon data is limited to 64 KiB; sendBeacon() returns false when the data does not fit.
  • fetch(url, { method: "POST", body, keepalive: true }) is the flexible alternative (custom headers, other methods), with the same 64 KiB in-flight limit for keepalive bodies.
  • Beacons go through your service worker. In tests for this page with Chromium 147 and WebKit 26.4, both sendBeacon() and keepalive fetches from a controlled page were dispatched to the worker's fetch event (with request.keepalive === true). Make sure your worker does not try to cache or rewrite RUM requests: skip them in the fetch handler, or route them to the network with a static route so the worker does not even need to start.

Dimensions every PWA should record

A PWA's RUM is only as useful as its segmentation. Record these with every page view:

src/rum/context.js
// Context attached to every RUM event. All reads are cheap and synchronous.
export function pwaContext() {
  const nav = performance.getEntriesByType("navigation")[0];
  const displayMode = ["standalone", "minimal-ui", "fullscreen", "window-controls-overlay"]
    .find((mode) => matchMedia(`(display-mode: ${mode})`).matches) ?? "browser";

  return {
    // Was this page load controlled by a service worker? (A page's controller is
    // fixed at navigation time, except after clients.claim().)
    swControlled: Boolean(navigator.serviceWorker?.controller),
    // Did the worker take part in the navigation request itself?
    swNavigation: Boolean(nav && nav.workerStart > 0),
    displayMode, // "browser" = a tab; anything else = installed app window
    navigationType: nav?.type ?? "unknown", // navigate | reload | back_forward | prerender
    prerendered: Boolean(nav?.activationStart > 0) || document.prerendering === true,
    deliveryType: nav?.deliveryType ?? "", // Chromium, Safari 26.4+; see the next section
    protocol: nav?.nextHopProtocol ?? "",
    // Chromium-only hints; undefined elsewhere.
    effectiveType: navigator.connection?.effectiveType,
    saveData: navigator.connection?.saveData,
    deviceMemory: navigator.deviceMemory,
    hardwareConcurrency: navigator.hardwareConcurrency,
    appVersion: globalThis.__APP_VERSION__ ?? "dev", // injected at build time
    route: location.pathname.replace(/\/\d+(?=\/|$)/g, "/:id"), // group dynamic segments
  };
}

swControlled tells you whether the page can use the worker; swNavigation tells you whether the worker handled the navigation request (it can be controlled but skipped the navigation if a static route sent it to the network, or the fetch handler did not call respondWith()). displayMode separates installed-app launches from browser tabs; see Detecting Installed Apps for other signals. For deeper analytics architecture, including offline queuing of events, see Analytics for PWAs.

Measuring the service worker's impact

A service worker changes the timing of every request it intercepts. Three browser mechanisms let you see how: Navigation and Resource Timing fields, Server-Timing headers that the worker adds, and controlled experiments.

PerformanceNavigationTiming (for the document) and PerformanceResourceTiming (for subresources) expose timestamps and sizes that change when a worker is involved:

Field Meaning with a service worker
workerStart When the browser started the worker (if it was not running) or began dispatching the fetch event. 0 if no worker intercepted the request
fetchStart When the browser began fetching after the worker stage. fetchStart − workerStart approximates worker start-up plus fetch event dispatch
requestStart, responseStart Network request and first byte; for responses produced by the worker, responseStart is when the worker's response became available
transferSize Bytes received over the network, including headers. 0 for responses from any local cache
encodedBodySize, decodedBodySize Body size before and after content decoding
nextHopProtocol h2, h3, http/1.1; empty for responses the worker produced locally
deliveryType Chromium 117+ and Safari 26.4+. "", "cache", "navigational-prefetch" per the specification; see below
serverTiming Parsed Server-Timing header of the response, including one the worker added
workerRouterEvaluationStart, workerCacheLookupStart, router source fields Static routing timing and sources; see Static Routing API
activationStart Chromium only. For prerendered pages, when the page was activated; subtract it from paint times

The exact values differ between engines, and some of them are not specified precisely for worker-served responses. To give concrete guidance, this page's examples were checked against Chromium 147 and WebKit 26.4 (the Playwright builds), with a worker that served some responses from Cache Storage, some synthesized with new Response(), and some fetched from the network with fetch(event.request):

Observation Chromium 147 WebKit 26.4
deliveryType of a navigation or subresource served from Cache Storage "cache-storage" ""
deliveryType of a subresource synthesized by the worker "cache" ""
transferSize of worker cache hits and synthesized responses 0 0
transferSize of a navigation the worker fetched from the network Network size (for example 330) Network size
transferSize of a subresource the worker fetched from the network 0 Network size
nextHopProtocol of worker-produced responses "" ""
serverTiming of a response the worker synthesized with a Server-Timing header Exposed Exposed
fetchStart − workerStart on navigations ≥ 0 (about 2 ms after ServiceWorker.stopAllWorkers on a desktop machine) Could be negative (workerStart 1 ms, fetchStart 0 ms)

"cache-storage" is not in the Resource Timing specification's list of deliveryType values at the time of writing, so treat it as a Chromium-specific signal and keep your classification robust when it is absent. The WebKit build in these tests (the Playwright WebKit 26.4 build) returned the empty string for every worker-served response. Safari 26.4 does expose deliveryType, but the specification only defines "cache" for the HTTP cache, and an empty string for responses from a service worker is consistent with it, so do not expect "cache-storage" outside Chromium. The WebKit result is a reminder that timestamps are coarsened (to 1 ms here) and that the order of workerStart and fetchStart is not reliable in every engine: clamp worker time at 0 and aggregate over many samples.

The Chromium rows show why a heuristic alone is not enough: a subresource the worker fetched from the network and one it synthesized look identical (transferSize 0, deliveryType "cache"). A classification function built on those observations falls back to "unattributed" in that case, and prefers an explicit label from your own worker (next section) whenever it is present:

src/rum/classify.js
/**
 * Classifies how a navigation or resource was served. Returns one of:
 * "sw-cache", "sw-network", "sw-synth" (from our own Server-Timing label),
 * "sw-local" (worker answered without the network; cache or synthesized),
 * "sw-unattributed" (worker involved, engine does not say how),
 * "http-cache", "network", "prefetch" or "unknown".
 */
export function classify(entry) {
  const sw = entry.workerStart > 0;

  // 1. An explicit label from our own service worker beats every heuristic.
  const label = entry.serverTiming?.find((t) => t.name === "sw")?.description;
  if (label) return `sw-${label}`;

  // 2. Chromium's deliveryType.
  if (entry.deliveryType === "navigational-prefetch") return "prefetch";
  if (entry.deliveryType === "cache-storage") return "sw-cache";

  if (sw) {
    // Bytes crossed the wire: the worker fetched from the network (reported for
    // navigations in Chromium, for all requests in WebKit).
    if (entry.transferSize > 0) return "sw-network";
    // Chromium reports transferSize 0 and deliveryType "cache" both for
    // synthesized responses and for subresources the worker fetched, so it
    // cannot be attributed without a Server-Timing label.
    if (entry.deliveryType === "cache") return "sw-unattributed";
    return "sw-local";
  }

  if (entry.transferSize === 0 && entry.decodedBodySize > 0) return "http-cache";
  if (entry.transferSize > 0) return "network";
  return "unknown"; // cross-origin without Timing-Allow-Origin, or no data
}

export function workerTime(entry) {
  if (!(entry.workerStart > 0)) return null;
  return Math.max(0, entry.fetchStart - entry.workerStart);
}

Cross-origin resources hide most of these fields unless the response carries Timing-Allow-Origin; add it to your CDN and API responses if you want them classified.

Labeling responses with Server-Timing from the worker

The Server Timing specification explicitly lists service workers as a use case: a worker is a local proxy that can report whether a response came from the network or a cache and how long each step took. Because a Response the worker returns can carry any headers, the worker can add a Server-Timing entry, and the page reads it from serverTiming in Navigation and Resource Timing, in Chromium and WebKit alike in the tests above.

There is one trap, also confirmed in those tests: a response stored in Cache Storage keeps the headers it had when it was cached, including the origin server's Server-Timing. A cache hit therefore replays yesterday's server timings as if they were measured now. The worker should replace them:

sw-timing.js
// Helpers for a service worker to label its responses with Server-Timing.
// Import into your worker with importScripts("/sw-timing.js") or bundle it.

/**
 * Returns a copy of `response` whose Server-Timing header describes how the
 * worker produced it. Server timings stored with cached responses are dropped,
 * because they describe a past network fetch.
 */
function labelResponse(response, source, durationMs, { keepOriginTiming = false } = {}) {
  // Opaque and error responses cannot be read or rewrapped.
  if (!response || response.type === "opaque" || response.type === "opaqueredirect" || response.type === "error") {
    return response;
  }

  const headers = new Headers(response.headers);
  const entry = `sw;desc=${source};dur=${durationMs.toFixed(1)}`;
  if (keepOriginTiming && headers.has("Server-Timing")) {
    headers.set("Server-Timing", `${headers.get("Server-Timing")}, ${entry}`);
  } else {
    headers.set("Server-Timing", entry);
  }

  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

/** Cache-first with labeling. */
async function cacheFirstLabeled(request, cacheName) {
  const start = performance.now();
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  if (cached) {
    return labelResponse(cached, "cache", performance.now() - start);
  }
  const response = await fetch(request);
  if (response.ok && response.type === "basic") {
    await cache.put(request, response.clone());
  }
  // A fresh network response: keep the origin's server timings, add ours.
  return labelResponse(response, "network", performance.now() - start, { keepOriginTiming: true });
}
sw.js (excerpt)
importScripts("/sw-timing.js");

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (url.origin !== location.origin) return;
  if (url.pathname.startsWith("/rum/")) return; // never touch RUM beacons

  if (url.pathname.startsWith("/assets/")) {
    event.respondWith(cacheFirstLabeled(event.request, "assets-v42"));
  }
});

Rewrapping a response has costs: it creates a new stream (negligible), and it loses the response.redirected and response.url properties of the original, which matters for navigations that followed redirects. Apply it to subresources and to your app shell navigation, not to every navigation. Server-Timing values are visible to any script on the page, so do not put anything sensitive in the description.

Worker start-up in the field

Worker start-up time is the dominant cost of a controlled navigation when the worker is cold. The Navigation Timing approximation (fetchStart − workerStart, clamped at 0) is imperfect but useful in aggregate. Report it for every controlled navigation and look at its distribution by device class, display mode and how long ago the previous visit was:

src/rum/sw-navigation.js
import { classify, workerTime } from "./classify.js";
import { pwaContext } from "./context.js";

addEventListener("load", () => {
  // Wait a task so loadEventEnd and all fields are populated.
  setTimeout(() => {
    const nav = performance.getEntriesByType("navigation")[0];
    if (!nav) return;
    const base = nav.activationStart || 0; // prerendered pages: measure from activation
    queue({
      kind: "navigation",
      source: classify(nav),
      workerMs: workerTime(nav),
      ttfb: Math.max(0, Math.round(nav.responseStart - base)),
      domContentLoaded: Math.max(0, Math.round(nav.domContentLoadedEventEnd - base)),
      transferSize: nav.transferSize,
      serverTiming: nav.serverTiming?.map(({ name, description, duration }) => ({ name, description, duration })),
      ...pwaContext(),
    });
  }, 0);
});

const events = [];
function queue(event) {
  events.push(event);
}
addEventListener("visibilitychange", () => {
  if (document.visibilityState !== "hidden" || events.length === 0) return;
  const body = JSON.stringify(events.splice(0));
  if (!navigator.sendBeacon("/rum/events", body)) {
    fetch("/rum/events", { method: "POST", body, keepalive: true }).catch(() => {});
  }
});

Then chart p50 and p75 of workerMs split by whether the navigation came from a cold launch (for installed PWAs, most home-screen launches) and compare TTFB and LCP for swNavigation: true versus false. If controlled navigations have a worse TTFB than uncontrolled ones on mobile, your worker is costing more than it saves on navigations; the fixes are on the Navigation Preload and Static Routing API pages.

Holdback experiments

Comparing controlled with uncontrolled loads is confounded: uncontrolled loads are mostly first visits, which differ in many other ways. The rigorous approach is a holdback: for a small, random, sticky fraction of users (say 5%), the service worker is installed but its fetch handler does not call respondWith() for navigations (or for everything), and everything else stays the same. Assign the group by hashing a stable ID stored in IndexedDB, so both the worker and the page know it, record the group in your RUM context, and compare metrics between groups over a few weeks. This is the only way to state with confidence how much your caching strategy improves LCP for your users.

Tracking cache hit rates

A cache that misses most of the time costs a lookup and then goes to the network anyway. Cache hit rate is therefore one of the most useful PWA-specific metrics, and it is best measured from inside the worker, which knows which cache was consulted, whether the entry was missing or expired, and what the strategy did next.

Instrumenting the worker

sw-metrics.js
// Cache hit/miss counters inside the service worker, flushed in batches.
// Workers can be terminated whenever idle, so flush often and inside waitUntil().

const counters = new Map(); // key: `${cacheName}|${outcome}` -> { count, bytes }
let lastFlush = Date.now();
const FLUSH_INTERVAL_MS = 30_000;
const FLUSH_THRESHOLD = 200; // events

let pendingEvents = 0;

/**
 * Records one cache lookup outcome.
 * outcome: "hit" | "miss" | "stale" (served but revalidated) | "error"
 */
function recordCache(event, cacheName, outcome, response) {
  const key = `${cacheName}|${outcome}`;
  const entry = counters.get(key) ?? { count: 0, bytes: 0 };
  entry.count += 1;
  // Content-Length may be absent (compressed or streamed responses); count what we can.
  entry.bytes += Number(response?.headers.get("content-length") ?? 0);
  counters.set(key, entry);
  pendingEvents += 1;

  if (pendingEvents >= FLUSH_THRESHOLD || Date.now() - lastFlush > FLUSH_INTERVAL_MS) {
    event.waitUntil(flushMetrics());
  }
}

async function flushMetrics() {
  if (counters.size === 0) return;
  const payload = {
    swVersion: self.__SW_VERSION__ ?? "dev",
    at: Date.now(),
    counters: Object.fromEntries(counters),
  };
  counters.clear();
  pendingEvents = 0;
  lastFlush = Date.now();
  try {
    await fetch("/rum/sw-cache", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
      keepalive: true,
    });
  } catch {
    // Offline: drop the sample rather than growing memory. For exact accounting,
    // persist to IndexedDB and send later (e.g. from a sync event where supported).
  }
}

/** A stale-while-revalidate strategy that records its outcomes. */
async function staleWhileRevalidate(event, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(event.request);

  const network = fetch(event.request)
    .then(async (response) => {
      if (response.ok) await cache.put(event.request, response.clone());
      return response;
    });

  if (cached) {
    recordCache(event, cacheName, "stale", cached);
    event.waitUntil(network.catch(() => {})); // revalidate in the background
    return cached;
  }

  try {
    const response = await network;
    recordCache(event, cacheName, "miss", response);
    return response;
  } catch (error) {
    recordCache(event, cacheName, "error", null);
    throw error;
  }
}

Wire it into your fetch handler (event.respondWith(staleWhileRevalidate(event, "api-v3"))) or, with Workbox, write a plugin with cachedResponseWillBeUsed and fetchDidSucceed callbacks that call recordCache() (see Advanced Workbox). Useful derived metrics:

  • Hit rate by requests per cache: hit / (hit + miss) for cache-first caches, stale / (stale + miss) for stale-while-revalidate.
  • Hit rate by bytes: what fraction of bytes the cache saved, which matters for users on metered connections.
  • Precache miss rate: requests for precached URLs that missed. It should be near zero; if it is not, your precache manifest and your actual URLs disagree (query strings, trailing slashes, Vary headers).
  • Misses by reason: if you use expiration, count "expired" separately from "absent" to tune maxAgeSeconds and maxEntries.

Cross-checking from the page

Resource Timing gives a page-side view that needs no worker changes, useful for validating the worker's own numbers:

src/rum/cache-summary.js
import { classify } from "./classify.js";

/** Summarizes how this page's subresources were served, by count and bytes. */
export function cacheSummary() {
  const summary = {};
  for (const entry of performance.getEntriesByType("resource")) {
    const source = classify(entry);
    const bucket = (summary[source] ??= { count: 0, decodedBytes: 0 });
    bucket.count += 1;
    bucket.decodedBytes += entry.decodedBodySize || 0;
  }
  return summary;
}

Expect differences: the page only sees requests made by the page, not by the worker itself (precache updates, background revalidation), and Resource Timing's buffer may have overflowed on long-lived single-page apps unless you raised it.

Performance budgets and Lighthouse CI

A performance budget is a limit that a build is not allowed to exceed: on timings (LCP, TBT), on quantities (bytes of JavaScript, number of requests), or on scores. Loading Performance covers bundle-size budgets enforced by bundlers and tools such as size-limit; this section covers lab budgets with Lighthouse CI.

Lighthouse CI

Lighthouse CI (@lhci/cli, 0.15.1 at the time of writing) runs Lighthouse several times against your build, asserts on the results, and uploads reports. Its three phases:

  • collect: runs Lighthouse numberOfRuns times (3 by default) per URL, against a static directory (staticDistDir), a server it starts (startServerCommand), or deployed URLs. A puppeteerScript can prepare the browser before each run (log in, or install the service worker). settings passes Lighthouse flags.
  • assert: checks results against assertions or a preset (lighthouse:recommended, lighthouse:all, lighthouse:no-pwa), aggregating runs with median (default for numeric values), optimistic, pessimistic or median-run.
  • upload: to temporary-public-storage (public, auto-deleted links, for quick setups), an lhci server you host (history and comparisons), or the filesystem.

@lhci/cli 0.15.1 depends on Lighthouse 12.6.1, not 13, so its audit IDs are the pre-insight names (render-blocking-resources, uses-long-cache-ttl, unused-javascript, and so on). Use those names in assertions until Lighthouse CI moves to Lighthouse 13, and expect to rename them when it does. Lighthouse 12 also removed performance budgets (budget.json) from Lighthouse itself, so express budgets as Lighthouse CI assertions; resource budgets use the resource-summary audit's resource-summary:<type>:size and :count keys (sizes in bytes).

A configuration that measures both a cold (first-visit) and a warm (service-worker) run of the same pages is easiest as two configurations. First the default, cold:

lighthouserc.cold.json
{
  "ci": {
    "collect": {
      "startServerCommand": "npm run preview -- --port 4173",
      "startServerReadyPattern": "Local",
      "url": ["http://localhost:4173/", "http://localhost:4173/products/42"],
      "numberOfRuns": 5,
      "settings": {
        "onlyCategories": ["performance"],
        "preset": "desktop"
      }
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 180000 }],
        "resource-summary:stylesheet:size": ["warn", { "maxNumericValue": 60000 }],
        "resource-summary:third-party:count": ["warn", { "maxNumericValue": 5 }],
        "render-blocking-resources": ["warn", { "maxLength": 0 }],
        "unused-javascript": ["warn", { "maxLength": 2 }]
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}

Remove "preset": "desktop" to use Lighthouse's default mobile emulation and throttling, which is the stricter and usually more representative setting. Resource sizes in resource-summary are transfer sizes.

Then the warm configuration, which keeps storage and uses a Puppeteer script to install the service worker before collection:

lighthouserc.warm.json
{
  "ci": {
    "collect": {
      "startServerCommand": "npm run preview -- --port 4173",
      "startServerReadyPattern": "Local",
      "url": ["http://localhost:4173/", "http://localhost:4173/products/42"],
      "numberOfRuns": 5,
      "puppeteerScript": "./scripts/lhci-warm-sw.cjs",
      "puppeteerLaunchOptions": { "args": ["--no-sandbox"] },
      "settings": {
        "onlyCategories": ["performance"],
        "disableStorageReset": true,
        "throttlingMethod": "devtools"
      }
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 1500 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 1000 }],
        "server-response-time": ["warn", { "maxNumericValue": 150 }]
      }
    },
    "upload": { "target": "filesystem", "outputDir": "./lhci-warm" }
  }
}
scripts/lhci-warm-sw.cjs
/**
 * Lighthouse CI puppeteerScript: runs before each collection. Visits the URL so
 * the service worker installs and precaches, then leaves the worker running.
 * @param {import('puppeteer').Browser} browser
 * @param {{url: string}} context
 */
module.exports = async (browser, context) => {
  const page = await browser.newPage();
  try {
    await page.goto(context.url, { waitUntil: "networkidle0" });
    const active = await page.evaluate(async () => {
      if (!("serviceWorker" in navigator)) return false;
      const registration = await navigator.serviceWorker.ready;
      // Wait until installation (and precaching) has finished.
      await new Promise((resolve) => setTimeout(resolve, 2000));
      return Boolean(registration.active);
    });
    if (!active) throw new Error(`Service worker did not activate for ${context.url}`);
  } finally {
    await page.close();
  }
};

Two details matter here. throttlingMethod: "devtools" avoids the simulation model, which does not account for responses served by the worker (see Throttling and variability). And the warm thresholds are deliberately tighter than the cold ones: a precached shell should render in well under a second; if it does not, something in the worker's path is slow.

Running it in CI

.github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run build
      - name: Cold (first visit, no service worker)
        run: npx @lhci/[email protected] autorun --config=./lighthouserc.cold.json
      - name: Warm (service worker installed)
        run: npx @lhci/[email protected] autorun --config=./lighthouserc.warm.json

autorun runs collect, assert and upload in sequence and fails the job when an error-level assertion fails. Keep CI runners consistent (same machine type), use at least five runs, and prefer relative comparisons against the base branch (possible with an lhci server) over absolute thresholds when your runners are noisy.

Budgets that Lighthouse cannot see

Some PWA budgets need custom checks in your test suite or build (see Automated Testing):

  • Precache size and file count, from the generated manifest (a script is shown in Loading Performance).
  • Service worker script size: it is re-downloaded on update checks (triggered by navigations into its scope and by registration.update()) and evaluated on every worker start, so keep it small and cheap to evaluate.
  • Worker start-up time, measured with Playwright or Puppeteer by stopping the worker with ServiceWorker.stopAllWorkers and timing a navigation's fetchStart − workerStart over many runs.
  • Cache hit rate in the field (above), with an alert when the precache miss rate rises after a deployment.

Building dashboards that answer PWA questions

The dimensions recorded above only help if dashboards use them. A minimal set of views:

Question Metric Split by
Are we passing Core Web Vitals? p75 LCP, INP, CLS Device class; compare with CrUX
Does the service worker help navigations? p75 TTFB and LCP swNavigation true/false, holdback group
Are installed users getting a better experience? p75 LCP, INP displayMode
Is cold worker start-up a problem? p50/p75 workerMs Device class, displayMode
Are caches working? Hit rate by requests and bytes Cache name, app version
Did the last release regress? All of the above appVersion
How much traffic avoids the network entirely? Share of navigations served sw-cache or prefetch Route
Is iOS different? p75 LCP, INP (Safari 26.2+ reports both) Browser engine

Keep percentiles, not averages: performance distributions are long-tailed, and averages hide the users who are having the worst experience. Report p75 for comparison with CrUX, and p95 for alerting on tails.

Browser support

Support data as of September 2026. For live data see MDN: PerformanceNavigationTiming, PerformanceResourceTiming, Server-Timing, and caniuse.com.

Feature Chrome / Edge Firefox Safari (macOS, iOS)
PerformanceObserver with buffered ✅ ✅ ✅
Navigation Timing Level 2, workerStart ✅ ✅ ✅
transferSize, encodedBodySize, decodedBodySize ✅ ✅ ✅
Server-Timing / serverTiming ✅ ✅ ✅ (Baseline since March 2023)
deliveryType ✅ 117 ⚠️ ❌ ✅ 26.4
activationStart (prerender) ✅ 108 ❌ ❌
Static routing timing fields (workerRouterEvaluationStart, workerCacheLookupStart) ✅ 140 ❌ ✅ 27
Router source fields (workerMatchedRouterSource, workerFinalRouterSource) ❌ ❌ ✅ 27
navigator.sendBeacon() ✅ ✅ ✅
fetch() with keepalive ✅ ✅ ✅
DevTools extensibility (detail.devtools tracks) ✅ (Chrome DevTools) ❌ ❌
ServiceWorker.stopAllWorkers (DevTools Protocol) ✅ ❌ ❌

⚠️ Chromium reports "cache-storage" for Cache Storage hits in addition to the specified values; see the tests above. Static routing field names are covered on the Static Routing API page.

Common pitfalls

  • Lab numbers from a default Lighthouse run represent first visits only. The worker and its caches were cleared. Measure warm runs separately, with storage reset disabled.
  • Using simulated throttling for worker-served pages. Simulation models network requests; worker time and Cache Storage reads are not scaled. Use devtools or provided throttling for warm runs.
  • Mixing controlled and uncontrolled loads in one number. Always split by swNavigation and displayMode.
  • Treating fetchStart − workerStart as exact. It is an approximation, coarsened, and can be negative in WebKit. Clamp and aggregate.
  • Trusting Server-Timing on cached responses. Cache Storage replays the origin's header; replace it in the worker.
  • Letting the service worker cache or rewrite RUM beacons. Beacons are dispatched to the worker; skip them explicitly.
  • Flushing worker metrics only on a timer. Idle workers are terminated and in-memory counters are lost; flush inside waitUntil() in the event that crosses the threshold.
  • Comparing Lighthouse audit IDs across versions. Lighthouse CI 0.15.1 uses Lighthouse 12.6.1 names; Lighthouse 13 renamed many audits to insights.
  • Expecting CrUX to show iOS users, logged-in routes or soft navigations. It does not. That is what your RUM is for.

Debugging

  • Is my page controlled? navigator.serviceWorker.controller in the console, or the Application panel's Service workers section, which also has Stop, Update and Bypass for network.
  • What did the worker do for a request? In the Network panel, the Size column shows "(ServiceWorker)"; the Timing tab of a worker-handled request shows the service worker phases (start-up and respondWith) and any Server-Timing entries.
  • Why is my repeat-visit Lighthouse run identical to the first-visit run? Storage was reset. Check the report's runtime settings for Clear storage (or disableStorageReset in the JSON's configSettings).
  • Which requests came from where? Run performance.getEntriesByType("resource").map((e) => [e.name, e.deliveryType, e.transferSize, e.workerStart > 0]) in the console and compare with the classification above.
  • Speculative loads and prerenders skew navigation metrics; check activationStart and deliveryType === "navigational-prefetch", and the Application panel's Speculative loads view. See Loading Performance.

Further reading

On this site

External references