Skip to content

Performance

Performance in a Progressive Web App is ordinary web performance plus one powerful and double-edged component: the service worker, a script that sits between your pages and the network for every navigation and every request. Answered from Cache Storage, a request skips DNS, connection set-up, server time and the download, which is how PWAs launch instantly from the home screen and keep working offline. Handled carelessly, the same worker adds its own start-up time to the most important request of the visit and makes the site slower than it would be without one. This section explains both sides in depth, shows how to measure them with Core Web Vitals and service-worker-specific signals, and points to the architecture and code patterns that keep a PWA fast.

Key takeaways

  • A service worker speeds up repeat visits by answering from Cache Storage; it does nothing for a first visit, which has no worker yet, and first visits are part of your 75th percentile.
  • Starting a stopped worker costs time on the critical path: web.dev puts it at around 50 ms on desktop, around 250 ms on mobile, and over 500 ms in extreme cases. Chromium stops idle workers after about 30 seconds, so most launches start cold.
  • Network-first navigations need navigation preload (or a static routing race); cache-first navigations only help LCP if the cached HTML contains the content.
  • The Core Web Vitals are LCP ≤ 2.5 s, INP ≤ 200 ms and CLS ≤ 0.1 at p75; INP replaced FID on March 12, 2024. TTFB and FCP are the diagnostics that expose the worker's effect.
  • Segment field data by whether the worker served the navigation (workerStart > 0), display mode and navigation type; blended numbers hide both the benefit and the cost.
  • The app shell model gives the fastest first paint and the riskiest LCP. Choose it for app-like SPAs, not for content sites.

Why performance is different for a PWA

A PWA is held to two standards at once. As a website it is measured like any other: Core Web Vitals in the Chrome User Experience Report, Lighthouse in CI, conversion and bounce rates. As an installed app it is compared with native apps: users tap an icon on their home screen or dock and expect a window with content, not a blank screen followed by a spinner. Three properties of PWAs change how you approach performance:

  1. A second layer of routing. In a normal site, the browser decides how to fetch a resource (memory cache, HTTP cache, network). In a PWA, a service worker's fetch handler decides first, and only what it forwards reaches the HTTP cache and the network. Every caching decision you make there changes your metrics, and the worker's own execution is part of the cost.
  2. Very different first and repeat visits. The first visit is a plain website with no worker, plus the extra work of installing one. Later visits are served from caches you control. The distance between the two is often larger than in any other architecture, and your 75th percentile mixes them in proportions that depend on how often users come back.
  3. Long-lived sessions. Installed PWAs stay open for hours in their own window. Metrics that accumulate over the page's lifetime, such as INP and CLS, are influenced by everything the app does after load: background sync results rendering, update prompts appearing, lists growing from offline data.

A PWA does not get special thresholds: Google's Core Web Vitals apply to it exactly as to any page, and the Chrome User Experience Report includes installed WebAPKs on Android and Trusted Web Activities (both run through Chrome), while it excludes every iOS user. Core Web Vitals covers the coverage caveats.

How a service worker makes a PWA faster

Cache hits remove the network from the critical path

A request answered from Cache Storage skips every network phase. Compare what a navigation costs with and without a cache hit in the worker:

Phase Network response Cache Storage hit in a running worker
Redirects Possible, each a full round trip None: the cached response is the final one
DNS lookup Unless cached by the OS or browser None
TCP and TLS set-up Unless a connection is reused None
Request and server think time Server-dependent None
Download Size ÷ bandwidth Disk read
Worker overhead None (no worker) fetch event dispatch and the handler's own code

On a slow or high-latency connection, the difference is hundreds of milliseconds to several seconds per request. On a flaky connection ("lie-fi": connected but not delivering), it is the difference between a page and a spinner that never ends, because a network request may hang for a long time before failing while a cache read does not.

The benefits, by technique

Technique What it improves Where it is covered
Precaching the app shell or critical assets Repeat-visit FCP; offline launch App Shell Model, Precaching & Runtime Caching
Cache-first for fingerprinted assets Resource load duration of scripts, styles, fonts, images Caching Strategies
Stale-while-revalidate for API data and pages LCP on repeat visits, with content one visit stale Caching Strategies
Network-first with a timeout Bounded wait on flaky networks, fresh content otherwise Caching Strategies, Offline UX & Fallbacks
Streaming composed responses FCP from the cache, content from the network in one document Streaming Responses
Offline data in IndexedDB LCP without any network request Offline-First Data & Sync, IndexedDB

A cache hit is not free, but it is predictable: its cost depends on the device, not on the network the user happens to be on. That predictability is often worth more than the average gain, because it shrinks the slow tail that the 75th percentile measures.

How a service worker makes a PWA slower

Worker start-up on the critical path

A registered worker is not permanently running. The browser starts it when an event needs it and terminates it when it has been idle: about 30 seconds in Chromium and Firefox by default (WebKit keeps it running while pages of the origin are open; see Service Workers). When a navigation arrives for a stopped worker, the browser must create the worker's thread, load and evaluate its script, run its top-level code and dispatch the fetch event before anything can be answered. Jake Archibald's web.dev article on navigation preload puts that boot-up at "usually around 50ms", "more like 250ms" on mobile, and over 500 ms in extreme cases.

The worst-affected navigation is the most important one: the first navigation of a session, which is exactly how a user launches an installed PWA from the home screen or arrives from a search result.

flowchart LR
    N["Navigation"] --> Q{"fetch listener registered?"}
    Q -- No --> NET["Network, no worker cost"]
    Q -- Yes --> R{"Static route matches? (Chrome 123+, Safari 27)"}
    R -- "network / cache source" --> FAST["Browser answers without the worker"]
    R -- No --> S{"Worker running?"}
    S -- No --> BOOT["Start worker: thread, script, top-level code"]
    S -- Yes --> EV["Dispatch fetch event"]
    BOOT --> EV
    EV --> H{"Handler decides"}
    H -- "cache hit" --> CH["Cache read: fast, offline-safe"]
    H -- "network" --> NW["Network after start-up (unless navigation preload)"]
    H -- "no respondWith()" --> FALL["Network after start-up: pure overhead"]

Other ways a worker adds cost

  • Pass-through handlers. A fetch handler that calls fetch(event.request) for everything, or returns without responding, adds dispatch (and start-up, when cold) to every request and provides nothing. Chromium detects listeners with empty function bodies and skips them (from Chrome 115, per Chrome Platform Status), but it cannot detect a handler that merely passes requests through. See Handling Fetch Events.
  • Network-first navigations without preload. The network request cannot start until the worker is running, so every cold navigation costs start-up plus the full round trip. Navigation Preload overlaps the two; the Static Routing API can race the network against the handler or bypass the worker entirely.
  • Install-time precaching on the first visit. A first-time visitor's install event downloads everything in the precache list. Registered too early, it competes with the page's own LCP image and scripts for bandwidth. Register after the load event and precache only what the app needs.
  • Serving stale code. A cache-first worker keeps serving the previous release until the new worker activates. Users running old front-end code against a new API is a correctness problem that often shows up as slow, retrying, error-handling code paths. See Updating Service Workers.
  • Back/forward cache evictions. In Chrome, clients.claim(), postMessage() to a page in the back/forward cache, a new version activating, and unregistering all evict pages from the bfcache, turning an instant Back navigation into a full load.
  • Main-thread work in the page. The worker runs on its own thread, but the page code that talks to it does not: parsing large cached JSON responses, re-rendering views when a sync completes, and reloading on controllerchange all land on the main thread and in INP.

Mitigations at a glance

Problem Mitigation Support
Cold start delays network-first navigations Navigation preload Chrome 59, Firefox 99, Safari 15.4
Worker woken for requests it does not handle Static routing "network" / "cache" sources Chrome 123, Safari 27; not Firefox
Cold start and network both on the critical path Static routing "race-network-and-fetch-handler" Chrome 123, Safari 27
Worker adds nothing for part of the site Narrower registration scope, or no fetch listener at all All browsers
Precache competes with the first visit Register after load; smaller precache All browsers
Slow worker start-up script Keep top-level code minimal; avoid large importScripts() bundles All browsers

Support data as of September 2026; see the linked pages for per-browser details and caveats.

Experimental: ServiceWorkerAutoPreload

Chromium is experimenting with a browser-side fix for cold starts that needs no code from you. In ServiceWorkerAutoPreload mode, the browser issues a navigation's network request in parallel with the worker's start-up. If the fetch handler then calls fetch(event.request), that call resolves with the already-running request instead of starting a new one; if the handler answers from the cache, the preloaded request is discarded; and if the handler falls back, the browser uses the preloaded response directly. The explainer limits it to navigations, applies it heuristically (for example to sites whose handlers often fall back), and does not enable it where the site already uses navigation preload. As of September 2026 the Chrome Platform Status entry lists it as a developer trial behind a flag (since Chrome 140), controllable by the ServiceWorkerAutoPreloadEnabled enterprise policy, with a specification change proposed to the Service Workers spec. Do not rely on it: explicit navigation preload and static routing remain the portable answers. See the explainer and the chromestatus entry.

Where the time goes in a controlled navigation

Navigation Timing exposes the worker's involvement directly. For a navigation intercepted by a service worker, workerStart is the time just before the worker's thread was started (or, if it was already running, just before the fetch event was dispatched); it is 0 when no worker handled the request. The gap between workerStart and fetchStart approximates the worker's own overhead, and responseStart marks the first byte the worker handed back.

worker-overhead.js
// Log how much of TTFB the service worker accounted for on this navigation.
addEventListener("load", () => {
  const nav = performance.getEntriesByType("navigation")[0];
  if (!nav || nav.workerStart === 0) {
    console.log("Navigation not handled by a service worker");
    return;
  }
  console.table({
    "worker start-up + dispatch (ms)": Math.round(nav.fetchStart - nav.workerStart),
    "TTFB (ms)": Math.round(nav.responseStart),
    "transferSize (bytes, 0 = from a cache)": nav.transferSize,
    "controlled by": navigator.serviceWorker.controller?.scriptURL ?? "none",
  });
});

Run it once with the worker stopped (DevTools Application > Service workers > Stop) and once with it running, and you have the cold-start cost for your worker on your device. If you use static routing, Resource Timing adds router-specific fields (workerRouterEvaluationStart, workerCacheLookupStart, and the matched and final router sources) in Chromium and Safari 27; the Static Routing API page shows how to read them. In the field, collect the same numbers per navigation and look at their distribution; Measuring Performance shows how to build that into your RUM.

The metrics that matter

Core Web Vitals and diagnostics

Metric Good / poor threshold (p75) How a service worker influences it Details
LCP Largest Contentful Paint ≤ 2.5 s / > 4 s TTFB of the document (cache hit vs start-up + network); LCP resource from the cache Core Web Vitals
INP Interaction to Next Paint ≤ 200 ms / > 500 ms Indirectly: fast cached loads invite early taps during boot; sync and update handling on the main thread Core Web Vitals, Runtime Performance
CLS Cumulative Layout Shift ≤ 0.1 / > 0.25 Indirectly: skeleton-to-content swaps, late install and update banners, cached fonts Core Web Vitals
FCP First Contentful Paint ≤ 1.8 s / > 3 s Directly: a cached shell or page paints as soon as it is read Core Web Vitals
TTFB Time to First Byte ≤ 0.8 s / > 1.8 s Directly: includes worker start-up and the handler's work Core Web Vitals

INP replaced First Input Delay as a Core Web Vital on March 12, 2024. Since Safari 26.2 (December 2025) and Firefox 144 (October 2025), LCP and INP can be measured in every major engine; CLS remains Chromium-only.

Signals specific to PWAs

Core Web Vitals do not tell you whether the worker is doing its job. These signals do:

Signal How to measure Why it matters
Share of navigations served by the worker workerStart > 0 on the navigation entry Low values mean most users never get the cached path (first visits, short sessions, cleared data)
Worker start-up + dispatch time fetchStart - workerStart, p75 The cost you pay for having a worker; grows with script size and top-level work
Cache hit ratio Count hits and misses in the fetch handler; report in batches A cache-first strategy with a low hit ratio is a network-first strategy with extra steps
Install duration and precache size Time from install to activation; total bytes precached Long installs delay offline readiness and compete with first-visit loads
Update adoption Version reported by pages over time (for example a build ID in the HTML) Slow adoption means users run old code for days
Display mode matchMedia("(display-mode: standalone)") Installed users launch cold from an icon; browser-tab users often arrive warm
Back/forward cache restores pageshow with persisted === true; notRestoredReasons on misses Worker operations such as clients.claim() can cost you instant Back navigations

A worker can count its own cache hits without touching the page:

sw.js (excerpt)
// Counts cache hits and misses per request destination and reports them in
// batches; performance and fetch are available in worker scopes.
const stats = new Map(); // "document:hit" -> count
let flushTimer = null;

function record(request, hit) {
  const key = `${request.destination || "other"}:${hit ? "hit" : "miss"}`;
  stats.set(key, (stats.get(key) ?? 0) + 1);
  flushTimer ??= setTimeout(flushStats, 10_000);
}

async function flushStats() {
  flushTimer = null;
  if (!stats.size) return;
  const body = JSON.stringify({ type: "sw-cache-stats", counts: Object.fromEntries(stats) });
  stats.clear();
  try {
    await fetch("/rum/sw", { method: "POST", body, headers: { "Content-Type": "application/json" } });
  } catch {
    // Offline: drop the sample rather than growing memory in a worker that
    // may be terminated at any time.
  }
}

async function cacheFirst(event) {
  const cached = await caches.match(event.request);
  record(event.request, Boolean(cached));
  if (cached) return cached;
  return fetch(event.request);
}

Timers in a service worker are unreliable because the worker can be terminated when idle, so treat these counts as a sample, not a ledger. Make sure your fetch handler ignores requests to /rum/sw itself.

A performance workflow for PWAs

  1. Start from field data. Check CrUX (PageSpeed Insights or the CrUX API) for the origin's Core Web Vitals by device class, then your own RUM for the segments CrUX cannot see: iOS users, logged-in routes, installed users.
  2. Segment before you optimize. Split p75 LCP, INP and CLS by worker-served vs not, display mode, navigation type (navigate, reload, back-forward, bfcache restore, prerender) and device class. Most PWA performance problems live in one segment.
  3. Reproduce in the lab, in the right state. Default Lighthouse runs clear storage and measure a first visit without a worker. Reproduce repeat visits deliberately: load once, let the worker install, then measure cold (worker stopped) and warm.
  4. Fix the architecture before micro-optimizing. Network-first without preload, a shell for a content site, and a bloated precache are architectural problems no amount of image compression will fix.
  5. Guard against regressions. Budget the precache size, the worker script size and the JavaScript executed before first content, and check them in CI with Lighthouse and your own tests. See Lighthouse & Auditing and Automated Testing.
  6. Watch the release. Tag RUM beacons with the build ID and compare releases, since a caching change only reaches users when their worker updates.

Section guide

This section is organized from architecture to measurement:

  • App Shell Model explains the architecture that most PWA performance discussions start from: precaching a shell and routing every navigation to it. It covers when the shell fits and when server rendering wins, the LCP trade-off in terms of LCP subparts, streaming shells that combine a cached head with a server-rendered fragment, how Workbox, vite-plugin-pwa and Angular implement the pattern, and a complete framework-free implementation.
  • Core Web Vitals defines LCP, INP and CLS down to the performance APIs, explains how service worker caching changes each metric and its subparts, and shows how to collect them correctly with the web-vitals attribution build, including soft navigations, CrUX coverage caveats for PWAs, and the back/forward cache.
  • Loading Performance covers the loading pipeline in detail: critical rendering path, resource hints and priorities, code splitting, compression, images and fonts, and how each interacts with a service worker's caches.
  • Runtime Performance is about what happens after load: long tasks, yielding, rendering and layout costs, memory in long-lived installed apps, and moving work off the main thread.
  • Measuring Performance builds the measurement system: RUM pipelines, lab set-ups that exercise the worker, service-worker-specific metrics and dashboards segmented the way this page recommends.
  • App Shell Model


    Precache the shell, route every navigation to it, stream content in, and understand the LCP cost.

    App Shell Model

  • Core Web Vitals


    LCP, INP and CLS at the API level, how service workers move them, and field measurement with web-vitals.

    Core Web Vitals

  • Loading Performance


    Critical path, priorities, preloading, code splitting, images and fonts in a service-worker world.

    Loading Performance

  • Runtime Performance


    Long tasks, yielding, rendering costs and memory in long-lived installed apps.

    Runtime Performance

  • Measuring Performance


    RUM pipelines, realistic lab runs and the service-worker metrics that Core Web Vitals do not show.

    Measuring Performance

Where to start, by symptom

Symptom Likely cause Start with
Slow LCP only for installed users launching from the home screen Cold worker start-up on network-first navigations Navigation Preload, Core Web Vitals
Fast FCP, slow LCP, skeleton visible for a long time Client-rendered app shell waiting for JavaScript and an API App Shell Model
Good lab scores, poor field LCP Lab measures first visits without a worker; field is dominated by slow devices or networks Measuring Performance
Poor INP right after launch Boot and hydration work while users already tap the cached UI Runtime Performance
CLS appears minutes after load Update toasts, install banners, or re-rendered cached data in the document flow Core Web Vitals
Slow first visit, fast repeat visits Precaching competing with the first load; client-side rendering without server HTML Loading Performance, App Shell Model
Back button reloads the page instead of restoring it bfcache blocked by unload, clients.claim() or worker messages Core Web Vitals
Every request is slightly slower since adding a worker Pass-through fetch handler Handling Fetch Events, Static Routing API

Further reading

On this site

External references