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:
- 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
fetchhandler 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. - 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.
- 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
fetchhandler that callsfetch(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
installevent downloads everything in the precache list. Registered too early, it competes with the page's own LCP image and scripts for bandwidth. Register after theloadevent 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
controllerchangeall 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.
// 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:
// 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¶
- 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.
- 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.
- 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.
- 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.
- 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.
- 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-pwaand 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-vitalsattribution 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.
-
Core Web Vitals
LCP, INP and CLS at the API level, how service workers move them, and field measurement with
web-vitals. -
Loading Performance
Critical path, priorities, preloading, code splitting, images and fonts in a service-worker world.
-
Runtime Performance
Long tasks, yielding, rendering costs and memory in long-lived installed apps.
-
Measuring Performance
RUM pipelines, realistic lab runs and the service-worker metrics that Core Web Vitals do not show.
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
- App Shell Model
- Core Web Vitals
- Navigation Preload: removing worker start-up from navigations
- Static Routing API: answering requests without starting the worker
- Caching Strategies: the trade-offs behind every cache decision
- HTTP Caching & Service Workers: how Cache Storage and the HTTP cache interact
- SPA vs MPA PWAs: the architecture choice that shapes performance
- Production Checklist: performance items to verify before launch
External references
- web.dev: Web Vitals
- web.dev: Speed up service worker with navigation preloads
- web.dev: Interaction to Next Paint becomes a Core Web Vital on March 12
- Chrome for Developers: Chrome User Experience Report methodology
- Chrome for Developers: Service Worker Static Routing API
- MDN: PerformanceResourceTiming.workerStart
- W3C: Service Workers specification
- GoogleChrome/web-vitals