Skip to content

Core Web Vitals

Core Web Vitals are the three field metrics Google uses to summarize real-user experience: Largest Contentful Paint (LCP) for loading, Interaction to Next Paint (INP) for responsiveness, and Cumulative Layout Shift (CLS) for visual stability, each judged at the 75th percentile of page loads. For a Progressive Web App they are not just an SEO checkbox: a service worker sits in front of every navigation and subresource, so it can make LCP dramatically better (cache hits instead of round trips) or measurably worse (worker start-up on the critical path), and app-shell architectures shift work from the server to the main thread, which shows up in INP and CLS. This page explains how each metric is defined at the API level, how PWA techniques move it, and how to measure it correctly in the field.

Key takeaways

  • The thresholds are LCP ≤ 2.5 s, INP ≤ 200 ms and CLS ≤ 0.1 for "good", and LCP > 4 s, INP > 500 ms, CLS > 0.25 for "poor", assessed at the 75th percentile of page loads, separately for mobile and desktop.
  • INP replaced First Input Delay on March 12, 2024. FID is gone from CrUX, PageSpeed Insights and Search Console, and web-vitals v5 removed onFID().
  • A service worker changes LCP mainly through TTFB (worker start-up plus a cache read instead of a network round trip) and resource load duration (cached images and fonts). It rarely touches INP directly, because it runs off the main thread, but the architectures PWAs use (client rendering, hydration, update prompts) do.
  • Since Safari 26.2 (December 2025) and Firefox 144 (October 2025), LCP and INP can be measured in all three engines; CLS (Layout Instability API) is still Chromium-only.
  • Use the web-vitals library's attribution build (currently v6) to record why a metric is slow: LCP subparts, INP input delay / processing / presentation delay with Long Animation Frame scripts, and the element behind the largest layout shift.
  • CrUX only sees Chrome users on Android (including installed WebAPKs and Custom Tabs) and desktop, on public, indexable, sufficiently popular pages. Logged-in PWA routes and every iOS user are invisible to it, so you need your own RUM.
  • Back/forward cache restores count as separate page visits with near-instant LCP. clients.claim(), postMessage() to all clients, and unregistering a worker evict pages from Chrome's bfcache.

The three Core Web Vitals and their thresholds

Each metric has two boundaries that split values into three ratings. A page (or origin) passes a Core Web Vital when the 75th percentile of its page loads is in the "good" range, and passes the Core Web Vitals assessment when all three do. Google's tools segment the percentile by device class, so a PWA that is fast on desktop and slow on mid-range Android phones fails on mobile.

Metric Measures Good Needs improvement Poor Unit
LCP Largest Contentful Paint Loading: when the largest image, video or text block in the viewport rendered ≤ 2500 2500 – 4000 > 4000 ms
INP Interaction to Next Paint Responsiveness: worst-case latency from a click, tap or key press to the next frame ≤ 200 200 – 500 > 500 ms
CLS Cumulative Layout Shift Visual stability: the largest burst of unexpected layout shifts ≤ 0.1 0.1 – 0.25 > 0.25 unitless score
FCP First Contentful Paint (diagnostic) First text, image, SVG or non-white canvas painted ≤ 1800 1800 – 3000 > 3000 ms
TTFB Time to First Byte (diagnostic) Navigation start to the first byte of the document response ≤ 800 800 – 1800 > 1800 ms

The boundaries are inclusive on the "good" side: the web-vitals library rates a value "good" when it is less than or equal to the first threshold, "needs-improvement" when it is above that and less than or equal to the second, and "poor" above the second. The library exports the same arrays, so your dashboards never drift from Google's definition:

thresholds.js
import { CLSThresholds, INPThresholds, LCPThresholds, FCPThresholds, TTFBThresholds } from "web-vitals";

console.log(LCPThresholds); // [2500, 4000]
console.log(INPThresholds); // [200, 500]
console.log(CLSThresholds); // [0.1, 0.25]
console.log(FCPThresholds); // [1800, 3000]
console.log(TTFBThresholds); // [800, 1800]

Why the 75th percentile? A median would hide the long tail of slow devices and networks, and a 95th percentile would be dominated by outliers you cannot fix (a train tunnel, a thermally throttled phone). At p75, three out of four visits must meet the target. For a PWA this has a specific consequence: the percentile is computed across all page loads, cold and warm. If 60% of your loads are repeat visits served from the service worker cache and 40% are first visits over the network, p75 will usually land inside the first-visit distribution. Optimizing only the cached path (the part that is fun to optimize in a PWA) will not move your p75 if first visits are slow.

Why FCP and TTFB are not Core Web Vitals. They are diagnostic metrics: useful for explaining a bad LCP (is the server slow, or is rendering slow?) but not direct measures of the user experience. A service worker can drive TTFB close to zero for cached navigations without making the page any more useful if the content still arrives from a slow API.

How the metrics have changed

Core Web Vitals are versioned by their implementation. Chromium publishes a changelog for each metric, and several changes matter when you compare historical data:

Date or version Change Effect on PWAs
Chrome 91 (2021) CLS switches from "sum of all shifts" to the maximum session window Long-lived app sessions (a PWA left open for hours) stopped accumulating unbounded CLS
Chrome 112 (2023) LCP ignores low-entropy images (under 0.05 bits of image data per displayed pixel) Blurry or solid-color hero backgrounds in an app shell no longer count as the LCP
March 12, 2024 INP becomes a Core Web Vital, replacing FID Heavy client-side routing and hydration, previously invisible to FID, now fail the assessment
September 9, 2024 Deadline after which CrUX, PageSpeed Insights and Search Console no longer guarantee FID data Dashboards reading FID from the CrUX API break
Firefox 122 (January 2024) LargestContentfulPaint ships in Firefox First non-Chromium LCP in the field
Chrome 130 Transparent text is no longer LCP-eligible Text faded in from opacity: 0 does not become LCP until it is visible
Chrome 133 Coarsened renderTime exposed for cross-origin images without Timing-Allow-Origin LCP from third-party image CDNs is more accurate
Chrome 134 (March 2025) Non-urgent renderer tasks are deferred after input by default An implementation optimization that can lower INP without code changes
web-vitals 5.0.0 (May 2025) onFID() removed; browser support policy moves to Baseline Widely available Upgrade code that still calls onFID()
Firefox 144 (October 2025) PerformanceEventTiming.interactionId ships, making INP computable INP in Firefox
Safari 26.2 (December 2025) Largest Contentful Paint and Event Timing ship in WebKit LCP and INP on iOS and macOS Safari, including installed Home Screen web apps
Chrome 144, Firefox 144, Safari 26.2 performance.interactionCount available Accurate "1 ignored per 50 interactions" calculation without polyfills
Chrome 145 (2026) LayoutShift.sources rectangles reported in CSS pixels and sorted by per-source impact area The first source is the node that contributed most, which makes CLS attribution more reliable
Chrome 147 (February 2026) LCP entries are emitted when an element is larger than the largest painted element, no longer suppressed while a larger image is still downloading RUM sees intermediate candidates; Chrome's changelog states the final LCP and CrUX are unchanged
Chrome 150 (2026) Nested clicks, such as a <label> click forwarded to its control, get interactionId 0 Forwarded clicks are no longer counted as a second interaction
Chrome 151 (June 2026) Soft navigation measurement enabled by default; LCP entries for <video> delivered at the next frame after the first video frame SPA route changes can be measured as separate "pages"; hero videos report LCP promptly on idle pages
web-vitals 6.0.0 (July 2026) Soft navigation support (reportSoftNavs) App-shell PWAs can report per-route LCP, INP and CLS in Chromium

What the Safari and Firefox support means for CrUX

Browser support for the underlying APIs lets your own RUM measure LCP and INP for Safari and Firefox users. It does not add those users to the Chrome User Experience Report, which only collects data from Chrome. See CrUX coverage caveats for PWAs.

Largest Contentful Paint (LCP)

What counts as the largest contentful paint

LCP reports the render time of the largest image, text block or video visible in the viewport, relative to when the user started navigating to the page. The browser emits a largest-contentful-paint performance entry every time a new, larger candidate is painted, and the last entry emitted before the page receives user input is the page's LCP.

Elements that can be LCP candidates:

  • <img> elements (for animated images, the first frame).
  • <image> elements inside an <svg>.
  • <video> elements: the poster image or the first frame, whichever is presented first.
  • Elements with a background image loaded through url() (CSS gradients do not count).
  • Block-level elements containing text nodes or inline-level text children.

How the size is computed:

  • Only the part visible in the viewport counts; parts clipped, scrolled out or hidden by overflow are excluded.
  • For images, the size is the visible size or the intrinsic size, whichever is smaller. A 100×100 image stretched to fill a 1000×1000 box counts as 100×100.
  • For text, the size is the smallest rectangle that contains all the text nodes.
  • Margins, padding and borders are never included.

Heuristics that exclude a paint:

  • Elements with opacity: 0 (Chrome 86) and transparent text (Chrome 130).
  • Images that cover the full viewport, which are treated as backgrounds rather than content (Chrome 88).
  • Low-entropy images: those with less than 0.05 bits of image data per displayed pixel (Chrome 112). A 10 KB blurred LQIP stretched across a 1600×900 hero is ignored; the real image that replaces it is not.

When does the browser stop looking for new candidates? As soon as the user taps, scrolls or presses a key. After that, content is no longer "the load" but a reaction to the user. That rule has a PWA-specific consequence: in a client-rendered app shell, if the user taps the navigation drawer before the content view has rendered, LCP freezes on whatever the shell had painted so far (often the header text), which flatters the metric. Do not celebrate an LCP improvement without checking the LCP element in your attribution data.

Removed elements. Before Chrome 88, removing an element disqualified it as a candidate. Since Chrome 88, removal is ignored: a removed candidate stays the LCP until something larger is painted, so LCP entries grow monotonically in size. A skeleton screen large enough to be a candidate, which is then removed and replaced with smaller content, can therefore be reported as LCP. The web-vitals library (5.2.0 and later) falls back to LargestContentfulPaint.id when the element has been removed, so the attribution target stays readable.

Render time and cross-origin images. Every entry has a renderTime (when it was presented) and, for images, a loadTime. For cross-origin images served without a Timing-Allow-Origin header, browsers historically exposed only loadTime; Chrome 133 started exposing a coarsened renderTime instead. If your PWA loads hero images from a separate image CDN origin, send Timing-Allow-Origin: https://app.example.com from that CDN so both your RUM and the attribution data get precise timings.

Background tabs and prerendering. A page loaded in a background tab has no meaningful LCP, and Google's tools drop it. A page that was prerendered by speculation rules measures LCP from activationStart (when the user actually navigated) rather than from the navigation start of the hidden prerender. The web-vitals library handles both cases; hand-written observers usually do not.

The four LCP subparts

To make LCP actionable, break it down into four sequential phases. They add up exactly to the LCP value, and the web-vitals attribution build reports all four:

Subpart From → to Ideal share of LCP What a service worker can change
Time to First Byte Navigation start → first byte of the HTML ~40% Worker start-up is added; network round trip is removed on a cache hit
Resource load delay TTFB → start of the LCP resource request < 10% Grows when the LCP resource is only discovered after JavaScript runs (app shells)
Resource load duration Request start → LCP resource fully loaded ~40% Near zero when the image comes from Cache Storage; adds a worker hop when it does not
Element render delay LCP resource loaded → element rendered < 10% Grows with render-blocking CSS/JS, hydration and client-side templating

The target proportions come from web.dev's Optimize LCP guide. The key insight: the two "delay" subparts should be close to zero. Time spent there is time in which neither the network nor the renderer is working on the LCP. If the LCP element is text, there is no resource and the two resource subparts are 0.

How a service worker changes LCP

A service worker is in the path of both the navigation request (TTFB) and the LCP resource request (resource load delay and duration). Its effect depends entirely on whether it answers from the cache and whether it is already running:

flowchart TD
    A["Navigation to a controlled URL"] --> B{"Worker running?"}
    B -- No --> C["Start worker: thread, script evaluation, top-level code"]
    B -- Yes --> D["Dispatch fetch event"]
    C --> D
    D --> E{"Handler answers from?"}
    E -- "Cache Storage" --> F["TTFB = start-up + dispatch + cache read"]
    E -- "Network" --> G["TTFB = start-up + dispatch + full network round trip"]
    E -- "Navigation preload" --> H["TTFB = max(start-up, network) + dispatch"]
    F --> I["HTML parsed, LCP resource discovered"]
    G --> I
    H --> I
    I --> J{"LCP resource cached?"}
    J -- Yes --> K["Resource load duration: cache read"]
    J -- No --> L["Resource load duration: worker hop + network"]

The start-up cost is not theoretical. The web.dev article introducing navigation preload puts service worker boot-up at "usually around 50ms", "more like 250ms" on mobile, and "over 500ms" in extreme cases. Chromium terminates an idle worker after about 30 seconds without events, so the first navigation of most visits (from a search result, a bookmark, or a home-screen icon) finds it stopped. A PWA launched from the home screen almost always pays the cold-start cost on its most important navigation.

Concretely, for each navigation strategy:

Navigation strategy Cold worker TTFB Warm worker TTFB LCP risk
No service worker Network only Network only Baseline
Worker with no fetch listener Network only (browsers skip the worker) Network only None added
Network-first HTML, no preload Start-up + network Dispatch + network Worse than no worker, on every cold visit
Network-first HTML with navigation preload Roughly max(start-up, network) Network Close to baseline
Network-first HTML raced with "race-network-and-fetch-handler" static route Network (worker raced) Network Close to baseline; Chrome 123+ and Safari 27
Cache-first HTML (app shell or cached page) Start-up + cache read Cache read Much better TTFB; LCP then depends on what the cached HTML contains
Stale-while-revalidate HTML Start-up + cache read Cache read As cache-first, content one visit stale

Two conclusions follow:

  1. Network-first navigations must use navigation preload or a static routing race. Otherwise the worker makes LCP strictly worse on cold starts, the one case field data is full of.
  2. Cache-first navigations only help LCP if the cached HTML contains the LCP element, or at least references the LCP resource directly. An app shell served in 40 ms whose content view needs a 600 ms API call still has a slow LCP; the time simply moved from TTFB into resource load delay and element render delay.

PWA-specific LCP optimizations

Put the LCP element in the HTML the worker serves. For content pages, cache rendered pages (network-first with a cache fallback, or stale-while-revalidate for content that tolerates staleness) rather than a generic shell. For app shells, consider streaming the shell and the server-rendered content fragment into one response, so the LCP element arrives in the first response instead of after a client-side fetch.

Make the LCP resource discoverable and high priority. An LCP image referenced only from JavaScript or from a CSS background cannot start loading until that code runs. Put it in the markup with fetchpriority="high" (Chrome 101, Firefox 132, Safari 17.2), never loading="lazy", and give it explicit width and height:

product.html
<img
  src="/img/products/4711-1200.avif"
  srcset="/img/products/4711-600.avif 600w, /img/products/4711-1200.avif 1200w"
  sizes="(max-width: 700px) 100vw, 700px"
  width="1200" height="800"
  alt="Blue ceramic teapot, side view"
  fetchpriority="high"
  decoding="async">

If the element must be rendered by script (a client-side view), preload its resource as early as possible, for example from the shell's <head> for the default route, or with <link rel="preload" as="image" fetchpriority="high"> inserted by the router before it starts fetching the view's data.

Serve the LCP resource from the cache, and do not wake the worker for it if you cannot. A runtime cache for images (cache-first with an expiration policy) turns repeat-visit resource load duration into a cache read. For images that are not in the cache, the worker hop is pure overhead. In Chrome 123+ and Safari 27, a static route can send image requests straight to Cache Storage or the network without starting the worker:

sw.js
self.addEventListener("install", (event) => {
  if ("addRoutes" in event) {
    event
      .addRoutes([
        // Fingerprinted build assets: exact cache match, no worker start-up.
        // A miss goes to the network, never to the fetch handler.
        { condition: { urlPattern: "/assets/*" }, source: { cacheName: "assets-v42" } },
        // Uncached product images: go straight to the network.
        { condition: { requestDestination: "image", urlPattern: "/img/products/*" }, source: "network" },
      ])
      // Routes are an optimization: an invalid rule rejects this promise but
      // does not fail installation, so log it instead of ignoring it.
      .catch((error) => console.warn("Static routes rejected", error));
  }
  // ...precache as usual
});

Do not let precaching compete with the first visit's LCP. The install event of a first-time visitor downloads everything in your precache manifest. If you register the worker immediately, those requests compete for bandwidth with the page's own LCP image. Register after the load event (see Registration & Scope), and keep the precache list to what the app needs offline.

Avoid a no-op or pass-through fetch handler. A handler that calls fetch(event.request) for everything adds start-up and dispatch to every request and provides nothing. Chromium detects empty listeners and skips them, but not a pass-through one. If a route does not need the worker, exclude it with a static route or narrow the worker's scope.

Watch the element render delay in client-rendered views. Once the LCP image has loaded, it still cannot render until the JavaScript that inserts it has run and any render-blocking stylesheet has arrived. Cached JavaScript is fast to fetch but not free to parse and execute on a mid-range phone; code-split the first view and keep hydration work out of the path. See Loading Performance for render-blocking resources and priority hints in depth.

Observing LCP without a library

The web-vitals library handles the edge cases for you (background tabs, prerendering, bfcache, finalization on input), but seeing the raw API makes attribution data easier to read:

lcp-debug.js
// Logs every LCP candidate with the element, its size and the render time.
// Paste into the console or load as an early inline script while debugging.
const po = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // renderTime is 0 for cross-origin images without Timing-Allow-Origin
    // in browsers that do not expose a coarsened render time.
    const time = entry.renderTime || entry.loadTime;
    console.log("LCP candidate", {
      time: Math.round(time),
      size: entry.size, // visible area in CSS pixels squared
      element: entry.element, // null if the node was removed
      id: entry.id,
      url: entry.url, // "" for text
    });
  }
});
// buffered: true replays candidates emitted before the observer existed.
po.observe({ type: "largest-contentful-paint", buffered: true });

Pair it with the navigation entry to see whether the service worker handled the document: performance.getEntriesByType("navigation")[0].workerStart is 0 when no worker intercepted the navigation and a timestamp otherwise.

Interaction to Next Paint (INP)

What INP measures

INP observes the latency of every click, tap and keyboard interaction during the page's lifetime and reports (almost) the worst one. Hovering, scrolling and zooming are not interactions for INP. An interaction's latency runs from the moment the user acted to the moment the browser next presented a frame, and it is made of three phases:

sequenceDiagram
    participant U as User
    participant M as Main thread
    participant C as Compositor / GPU
    U->>M: pointerdown / keydown (timestamp)
    Note over M: Input delay: busy with other tasks
    M->>M: Run event listeners (pointerdown, pointerup, click)
    Note over M: Processing duration
    M->>M: rAF callbacks, style, layout, paint
    M->>C: Commit frame
    Note over M,C: Presentation delay
    C-->>U: Next frame presented (INP end point)
Phase Starts Ends Typical causes in PWAs
Input delay User input First event listener starts Long tasks during start-up: hydration, route bundles evaluating, IndexedDB result processing, analytics
Processing duration First listener starts Last listener for the interaction ends Synchronous state updates, re-rendering large lists, localStorage reads, JSON parsing
Presentation delay Last listener ends Next frame presented Large style recalculation and layout, forced layouts in rAF, big DOM updates, off-main-thread raster work

How the page's INP value is chosen: for most pages the interaction with the worst latency is reported, but for every 50 interactions one of the highest is ignored, so a page with 120 interactions reports its third-worst. This keeps a single hiccup from dominating a long session, which matters for PWAs that stay open for hours. A page without any qualifying interaction has no INP at all, which is why INP sample counts are lower than LCP sample counts.

The Event Timing API underneath

INP is computed from event performance entries (PerformanceEventTiming). A few details explain numbers you will see in your data:

  • interactionId groups the events that make up one interaction (for a tap: pointerdown, pointerup, click). Entries with interactionId === 0 are not interactions (for example mouseover). Chrome 150 assigns 0 to nested clicks, such as a <label> click forwarded to its form control, so the forwarded click is not double counted.
  • duration is rounded to the nearest 8 ms, so INP values cluster on multiples of 8.
  • durationThreshold controls which entries are delivered: the default is 104 ms and the minimum is 16 ms. The web-vitals library observes with a 40 ms threshold once it has loaded (configurable with its own durationThreshold option), and always observes the first-input entry so there is always an INP candidate.
  • performance.interactionCount (Chrome 144, Firefox 144, Safari 26.2) returns the number of interactions so far, needed for the "one per 50" rule. Older browsers need an estimate.
  • processingStart and processingEnd mark the first listener starting and the last one ending, giving you the three phases: input delay = processingStart - startTime, processing = processingEnd - processingStart, presentation delay = startTime + duration - processingEnd.

How PWAs affect INP

A service worker runs on its own thread, so its fetch handler, cache reads and IndexedDB work do not block the main thread and do not add to INP directly. What does affect INP in a PWA:

  • Start-up work on a cached load. When the shell and scripts come from Cache Storage, the page becomes visible quickly, and users start tapping immediately, while the framework is still hydrating or the router is still evaluating route modules. Those interactions pay a large input delay. The web-vitals attribution reports loadState for the INP interaction; a cluster of poor INP with loadState: "dom-interactive" or "dom-content-loaded" is the signature of this problem.
  • Awaiting data before giving feedback. INP ends at the next frame, not when your async work finishes. A click handler that awaits a network response before changing anything on screen still produces a next frame quickly, just an unchanged one, so INP looks fine while the user sees nothing happen. Conversely, a handler that synchronously re-renders a large view before yielding has a long processing duration. Give immediate feedback (pressed state, spinner, optimistic update) in the first frame, then do the rest.
  • Synchronous storage. localStorage and sessionStorage are synchronous and can block for a noticeable time on large values. Move persisted state to IndexedDB (asynchronous; see IndexedDB).
  • Update prompts and reloads. Reacting to controllerchange with location.reload() in the middle of an interaction discards the frame. Show an update prompt instead and reload on the user's explicit choice (Updating Service Workers).
  • Large DOM from offline data. Offline-first apps often render everything they have locally. Rendering 5,000 cached list items in one task is a long task no matter where the data came from. Virtualize, paginate, or use content-visibility: auto (Chrome 85, Firefox 125, Safari 18).
  • Soft navigations. Every route change in an app shell is an interaction, so a slow client-side route transition is directly an INP problem, even when the network is not involved.

Optimizing INP: yield, split and defer

The single most effective technique is to yield to the main thread between the part of a handler that produces visible feedback and the part that does bookkeeping. scheduler.yield() (Chrome 129, Firefox 142; not in Safari as of September 2026) continues your task with priority over other queued tasks; a setTimeout fallback works everywhere but lets other tasks run first:

yield.js
/**
 * Yield to the main thread so the browser can paint and handle input.
 * scheduler.yield() resumes ahead of other queued tasks; the fallback
 * resumes after them.
 */
export function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return globalThis.scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}
add-to-cart.js
import { yieldToMain } from "./yield.js";
import { saveCart } from "./db.js"; // IndexedDB wrapper, asynchronous

document.querySelector("#add-to-cart").addEventListener("click", async (event) => {
  const button = event.currentTarget;

  // 1. Visible feedback first: this is what the next frame should show.
  button.disabled = true;
  button.textContent = "Added";
  updateCartBadge(+1);

  // 2. Let the browser present that frame before doing anything else.
  await yieldToMain();

  // 3. Non-urgent work after the paint: persistence, analytics, sync.
  try {
    await saveCart();
    navigator.serviceWorker?.controller?.postMessage({ type: "cart-changed" });
  } catch (error) {
    // Revert the optimistic update and tell the user; never fail silently.
    updateCartBadge(-1);
    button.disabled = false;
    button.textContent = "Add to cart";
    showToast("Could not save your cart. Please try again.");
    console.error(error);
  }
});

Other INP techniques, in rough order of payoff for PWAs:

  1. Break up start-up work. Code-split by route, lazy-load non-critical widgets, and chunk hydration so that input arriving during start-up is handled within a frame or two. See Runtime Performance.
  2. Avoid layout thrashing in handlers and requestAnimationFrame callbacks: batch DOM reads before writes.
  3. Move heavy computation off the main thread into a dedicated worker (search indexing, diffing sync payloads, image processing). The service worker is the wrong place for it: it is shared by all tabs, may be terminated when idle, and is busy handling fetches.
  4. Keep the DOM small and use CSS containment for off-screen sections.
  5. Debounce input-driven work (search-as-you-type) and cancel stale work with AbortController.

Long Animation Frames for INP attribution

In Chromium (Chrome 123+), the Long Animation Frames API (long-animation-frame entries) reports frames that took longer than 50 ms, with a scripts array of PerformanceScriptTiming entries (sourceURL, sourceFunctionName, invoker, invokerType, forcedStyleAndLayoutDuration, pauseDuration). The web-vitals attribution build intersects these with the INP interaction and summarizes them as longestScript, totalScriptDuration, totalStyleAndLayoutDuration, totalPaintDuration and totalUnattributedDuration. In a PWA this is how you find out that the slow interaction was blocked by, say, your IndexedDB sync code running a large JSON.parse in the input delay, rather than by the click handler itself.

Cumulative Layout Shift (CLS)

How CLS is calculated

Every time a visible element changes its start position between two frames without the user causing it, the browser records a layout shift with a score:

layout shift score = impact fraction × distance fraction

  • The impact fraction is the union of the visible areas the unstable elements occupied in the previous and the current frame, as a fraction of the viewport.
  • The distance fraction is the greatest distance any unstable element moved (horizontally or vertically), divided by the viewport's largest dimension.

Example: a banner inserted at the top pushes an article down by 25% of the viewport height, and the article's before-and-after area covers 75% of the viewport: 0.75 × 0.25 = 0.1875, poor on its own.

Shifts are grouped into session windows: a window collects shifts that occur less than 1 second apart, and lasts at most 5 seconds. CLS is the score of the largest window over the page's whole lifetime. This is why a PWA left open all day does not accumulate an ever-growing CLS, but also why a single bad burst at any time, not just during load, fails the page.

What does not count:

  • Shifts within 500 ms of a discrete user input (tap, click, key press). Those entries carry hadRecentInput: true and are excluded. Scrolling is not a qualifying input, so content shifting during a scroll counts.
  • Changes made with CSS transform (animations and transitions do not change layout positions).
  • New elements being added, or elements changing size, as long as nothing else moves as a result.
  • Shifts of elements with opacity: 0 (Chrome 89) and several other invisible cases.

The Layout Instability API that exposes layout-shift entries is Chromium-only. Firefox and Safari do not implement it, so your RUM CLS data is Chrome, Edge and other Chromium browsers only. The CrUX data is Chrome-only anyway, so this is not a gap relative to Google's assessment, but it is one for Safari-heavy audiences.

Where PWAs cause layout shifts

  • Skeleton-to-content swaps in app shells. A skeleton whose rows are shorter than the real content, or a view that renders "Loading…" and then the article, shifts everything below. Make skeletons the same dimensions as the content they stand in for, or reserve the space with min-height / aspect-ratio.
  • Late banners: install prompts, update toasts, offline indicators, cookie notices. Inserting any of these into the document flow at the top of the page shifts the whole viewport. Render them as position: fixed overlays, or reserve their slot in the shell. The custom install UI triggered by beforeinstallprompt is a classic culprit because the event fires at an unpredictable time after load.
  • Web fonts. A cached font from the service worker usually arrives in time for first render on repeat visits, but on first visits a late font swap reflows text. Use font-display: optional for body text or match fallback metrics with size-adjust, ascent-override and descent-override (size-adjust: Chrome 92, Firefox 92, Safari 17).
  • Images and embeds without dimensions. Always set width and height attributes (the browser derives the aspect ratio from them) or aspect-ratio in CSS.
  • Content injected from cache, then from network. A stale-while-revalidate view that renders cached data and then re-renders with fresh data of a different length causes a shift outside the 500 ms input window. Either keep the layout stable (fixed-height rows) or show a "New items available" affordance instead of re-rendering in place.
  • Display-mode differences. In standalone and window-controls-overlay display modes, env(safe-area-inset-*) and env(titlebar-area-*) values can change after first layout (for example when the window controls overlay is toggled). Build the title bar area into the initial layout instead of adjusting it in script after load.
shell.css
/* Reserve space for elements that arrive after first paint. */
.update-toast,
.install-banner {
  position: fixed; /* overlays never shift the document */
  inset-inline: 1rem;
  bottom: calc(1rem + env(safe-area-inset-bottom));
  transform: translateY(150%); /* start off-screen */
  transition: transform 200ms ease-out; /* transform animations are not layout shifts */
}
.update-toast[data-visible],
.install-banner[data-visible] {
  transform: translateY(0);
}

/* Skeleton rows match real rows, so the swap does not move anything. */
.list-row,
.list-row--skeleton {
  block-size: 72px;
  contain: layout paint;
}

/* Media keeps its box before the bytes arrive. */
.card img {
  inline-size: 100%;
  block-size: auto;
  aspect-ratio: 3 / 2;
}

Observing layout shifts and session windows

cls-debug.js
// Reproduces the CLS session-window algorithm and logs the nodes that moved.
let sessionValue = 0;
let sessionEntries = [];
let cls = 0;

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue; // expected: within 500 ms of input

    const first = sessionEntries[0];
    const last = sessionEntries[sessionEntries.length - 1];
    // New window if more than 1 s since the last shift or 5 s since the first.
    if (
      sessionEntries.length &&
      (entry.startTime - last.startTime >= 1000 || entry.startTime - first.startTime >= 5000)
    ) {
      sessionValue = 0;
      sessionEntries = [];
    }
    sessionValue += entry.value;
    sessionEntries.push(entry);
    cls = Math.max(cls, sessionValue);

    console.log("layout shift", entry.value.toFixed(4), "CLS so far", cls.toFixed(4));
    for (const source of entry.sources) {
      // Chrome 145+ reports rects in CSS pixels and sorts sources by impact area.
      console.log("  moved:", source.node, source.previousRect, "→", source.currentRect);
    }
  }
}).observe({ type: "layout-shift", buffered: true });

The web-vitals attribution build reports the same information for the largest shift in the worst window as largestShiftTarget, largestShiftValue, largestShiftTime, largestShiftEntry, largestShiftSource and loadState.

TTFB and FCP: diagnosing the start of the load

Time to First Byte in a service-worker-controlled page

TTFB measures the time from the start of the navigation (startTime, which is 0 for the navigation entry) to responseStart, the moment the first byte of the document response arrives. According to web.dev, it covers redirect time, service worker start-up time (if applicable), DNS lookup, connection and TLS negotiation, and the request up to the first response byte. The thresholds are 800 ms (good) and 1800 ms (poor).

For a navigation answered by a service worker, "first byte" means the first byte of the Response your fetch handler passed to respondWith(). That makes TTFB the most direct measure of what your worker does to navigations:

  • A cache hit replaces DNS, connection and server time with a Cache Storage read, but adds worker start-up and event dispatch.
  • A network response through the worker adds start-up and dispatch on top of the full network time, unless navigation preload or a static routing race overlaps them.
  • A streamed response (shell head from the cache, body from the network) has the TTFB of the cached part, which is why streaming can make TTFB look excellent while LCP still waits for the network.

The web-vitals attribution build splits TTFB into waitingDuration (up to the start of request handling, mostly redirects), cacheDuration, dnsDuration, connectionDuration and requestDuration. Its documentation notes that for navigations handled by a service worker, cacheDuration "usually includes service worker start-up time as well as time processing fetch event listeners", with exceptions tracked in the Navigation Timing specification's issue 199. MDN suggests fetchStart - workerStart as the worker's processing time. Both are approximations; use them for trends, not absolute truths.

navigation-breakdown.js
// Breaks the navigation down into phases, including service worker time.
// Run after the load event so every field is populated.
function navigationBreakdown() {
  const nav = performance.getEntriesByType("navigation")[0];
  if (!nav) return null;

  const viaWorker = nav.workerStart > 0;
  return {
    type: nav.type, // "navigate" | "reload" | "back_forward" | "prerender"
    viaWorker,
    // Time the worker needed before the browser's own fetch steps began:
    // start-up (if it was not running) plus fetch event dispatch.
    workerTime: viaWorker ? Math.round(nav.fetchStart - nav.workerStart) : 0,
    redirect: Math.round(nav.redirectEnd - nav.redirectStart),
    dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
    connect: Math.round(nav.connectEnd - nav.connectStart),
    // For a Cache Storage hit these network phases are 0 and requestStart
    // may equal responseStart.
    request: Math.round(nav.responseStart - nav.requestStart),
    // Prerendered pages: measure from activation; clamp, because the first
    // byte may have arrived before the user activated the page.
    ttfb: Math.round(Math.max(0, nav.responseStart - (nav.activationStart || 0))),
    transferSize: nav.transferSize, // 0 for responses from caches
    serverTiming: nav.serverTiming?.map(({ name, duration }) => ({ name, duration })),
  };
}

addEventListener("load", () => {
  // Wait a task so loadEventEnd is set.
  setTimeout(() => console.table(navigationBreakdown()), 0);
});

Early Hints (103). If your server sends 103 Early Hints (Chrome 103, Firefox 120, Safari 17), the interim response counts as the first bytes for TTFB. From Chrome 115 to Chrome 132, Chrome's responseStart measured the start of the final response instead; web.dev reports that this created compatibility issues across many tools and was reverted in Chrome 133, which exposes the final-response time separately as finalResponseHeadersStart (the 103 itself is firstInterimResponseStart, Chrome 115). Early Hints only helps navigations that actually go to the network: a navigation answered from Cache Storage never sees the 103.

Prerendered pages. For pages activated from a speculation-rules prerender, measure from activationStart, not from 0. The navigation itself happened in the background before the user clicked. activationStart is Chromium-only (Chrome 108+).

First Contentful Paint

FCP marks the first paint of text, an image (including background images), an <svg>, or a non-white <canvas>. Good is 1.8 s or less; poor is over 3.0 s. For a PWA, FCP is the metric that best captures the benefit of an app shell: the header, navigation and skeleton paint as soon as the cached shell is parsed. The gap between FCP and LCP (the attribution build's firstByteToFCP and the LCP subparts) then tells you how long users stare at a skeleton. A fast FCP with a slow LCP is the typical profile of a client-rendered shell; a slow FCP with LCP right behind it is the typical profile of a server-rendered page with a slow TTFB.

Measuring Core Web Vitals with the web-vitals library

The web-vitals library is Google's reference implementation of the metrics in JavaScript, written to match how Chrome measures them for CrUX. As of September 2026 the current release is 6.2.2 (September 14, 2026). Version 6.0.0 (July 21, 2026) added soft navigation support; version 5.0.0 (May 7, 2025) removed onFID() and moved the support policy to Baseline Widely available.

API surface

Export Reports Notes
onLCP(callback, opts?) LCP Finalized on the first input or when the page is hidden; reported again after bfcache restores
onINP(callback, opts?) INP Reported when the page is hidden (and again later if it grows); not reported without interactions
onCLS(callback, opts?) CLS Chromium only; reported when the page is hidden
onFCP(callback, opts?) FCP Not reported for pages loaded in the background
onTTFB(callback, opts?) TTFB Waits for the load event so the whole navigation entry is populated
LCPThresholds, INPThresholds, CLSThresholds, FCPThresholds, TTFBThresholds [good, poor] arrays Use metric.rating rather than recomputing

Every callback receives a Metric object:

Property Type Meaning
name "CLS" \| "FCP" \| "INP" \| "LCP" \| "TTFB" The metric
value number Current value (ms, or unitless for CLS)
rating "good" \| "needs-improvement" \| "poor" Against the thresholds above
delta number Change since the last report of this metric instance; equal to value on the first report
id string Unique per metric instance; a bfcache restore gets a new id
entries PerformanceEntry[] The entries used for the value (may be empty, for example CLS 0)
navigationType "navigate" \| "reload" \| "back-forward" \| "back-forward-cache" \| "prerender" \| "restore" \| "soft-navigation" restore means the tab was discarded and restored; back-forward-cache means a bfcache restore
navigationId, navigationURL, navigationStartTime, navigationInteractionId Identify which (soft) navigation the metric belongs to; use navigationURL instead of location.href, because metrics can be reported after the URL has changed

Options (ReportOpts): reportAllChanges (report every change, useful for debugging, not for production) and reportSoftNavs. onINP also accepts durationThreshold (default 40). In the attribution build, every function accepts generateTarget(el) to replace the default CSS-selector-style element description; onINP accepts includeProcessedEventEntries (default false since v6); onLCP accepts resourceBufferSize (default 50 extra Resource Timing entries beyond the browser's default 250, added in 6.1.0).

What the attribution build adds

Import from web-vitals/attribution instead of web-vitals (about 1.5 KB brotli larger) and each metric gains an attribution object:

Metric Attribution fields What to log for a PWA
LCP target, url, timeToFirstByte, resourceLoadDelay, resourceLoadDuration, elementRenderDelay, navigationEntry, lcpResourceEntry, lcpEntry All four subparts; lcpResourceEntry.workerStart > 0 tells you the LCP resource went through the service worker; lcpResourceEntry.transferSize === 0 suggests a cache
INP interactionTarget, interactionType, interactionTime, nextPaintTime, inputDelay, processingDuration, presentationDelay, loadState, longAnimationFrameEntries, longestScript, totalScriptDuration, totalStyleAndLayoutDuration, totalPaintDuration, totalUnattributedDuration, processedEventEntries Target, type, the three phases, loadState, and the longest script's sourceURL, sourceFunctionName and subpart
CLS largestShiftTarget, largestShiftTime, largestShiftValue, largestShiftEntry, largestShiftSource, loadState Target, value, time and load state
FCP timeToFirstByte, firstByteToFCP, loadState, fcpEntry, navigationEntry TTFB and the TTFB-to-FCP gap
TTFB waitingDuration, cacheDuration, dnsDuration, connectionDuration, requestDuration, navigationEntry All five; cacheDuration approximates worker time for controlled navigations

A production RUM module for a PWA

The module below measures all five metrics with attribution, adds the dimensions that matter for a PWA (display mode, whether a service worker controlled the page and served the navigation, the worker's version), batches reports, and flushes them when the page is hidden. visibilitychange to hidden is the last reliable moment to send data: on mobile, users switch apps and the OS kills the tab without an unload event, and unload handlers also block the back/forward cache.

src/rum.js
import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals/attribution";

const ENDPOINT = "/rum"; // same origin: no CORS preflight, not blocked by most privacy lists
const SAMPLE_RATE = 0.25; // decide once per page load, not per metric
const MAX_BATCH_BYTES = 60_000; // stay under the 64 KiB keepalive / sendBeacon budget

const sampled = Math.random() < SAMPLE_RATE;
const queue = [];

/** Current display mode, as the manifest's display member resolved it. */
function displayMode() {
  const modes = ["window-controls-overlay", "fullscreen", "standalone", "minimal-ui"];
  return modes.find((mode) => matchMedia(`(display-mode: ${mode})`).matches) ?? "browser";
}

/** PWA context captured once, when the module loads. */
function pageContext() {
  const nav = performance.getEntriesByType("navigation")[0];
  return {
    displayMode: displayMode(),
    // A controller exists when a worker controls this document. It is null on
    // a hard reload (Shift+reload bypasses the worker) and on the first visit.
    swControlled: Boolean(navigator.serviceWorker?.controller),
    // workerStart > 0 means the navigation request itself was intercepted.
    swNavigation: nav ? nav.workerStart > 0 : null,
    // Set by the service worker build (see note below); "none" without one.
    swVersion: document.documentElement.dataset.swVersion ?? "none",
    // Chromium-only hints; undefined elsewhere, which your backend must accept.
    effectiveType: navigator.connection?.effectiveType,
    deviceMemory: navigator.deviceMemory,
  };
}

const context = pageContext();

/** Reduce the attribution object to small, serializable fields. */
function summarizeAttribution(metric) {
  const a = metric.attribution ?? {};
  switch (metric.name) {
    case "LCP":
      return {
        target: a.target,
        url: a.url,
        ttfb: a.timeToFirstByte,
        loadDelay: a.resourceLoadDelay,
        loadDuration: a.resourceLoadDuration,
        renderDelay: a.elementRenderDelay,
        resourceViaSW: a.lcpResourceEntry ? a.lcpResourceEntry.workerStart > 0 : null,
        resourceTransferSize: a.lcpResourceEntry?.transferSize ?? null,
      };
    case "INP":
      return {
        target: a.interactionTarget,
        type: a.interactionType,
        inputDelay: a.inputDelay,
        processing: a.processingDuration,
        presentation: a.presentationDelay,
        loadState: a.loadState,
        script: a.longestScript
          ? {
              url: a.longestScript.entry.sourceURL,
              fn: a.longestScript.entry.sourceFunctionName,
              invoker: a.longestScript.entry.invoker,
              subpart: a.longestScript.subpart,
              duration: Math.round(a.longestScript.intersectingDuration),
            }
          : null,
      };
    case "CLS":
      return {
        target: a.largestShiftTarget,
        value: a.largestShiftValue,
        time: a.largestShiftTime,
        loadState: a.loadState,
      };
    case "FCP":
      return { ttfb: a.timeToFirstByte, firstByteToFCP: a.firstByteToFCP, loadState: a.loadState };
    case "TTFB":
      return {
        waiting: a.waitingDuration,
        cache: a.cacheDuration, // includes worker start-up for controlled navigations
        dns: a.dnsDuration,
        connection: a.connectionDuration,
        request: a.requestDuration,
      };
    default:
      return {};
  }
}

function enqueue(metric) {
  queue.push({
    name: metric.name,
    value: Math.round(metric.name === "CLS" ? metric.value * 1000 : metric.value), // CLS in thousandths
    delta: Math.round(metric.name === "CLS" ? metric.delta * 1000 : metric.delta),
    rating: metric.rating,
    id: metric.id, // dedupe key: the backend keeps the last value per id
    navigationType: metric.navigationType,
    url: metric.navigationURL ?? location.href,
    attribution: summarizeAttribution(metric),
  });
}

function flush() {
  if (!queue.length) return;
  const body = JSON.stringify({ context, metrics: queue.splice(0) });
  const blob = new Blob([body], { type: "application/json" });
  // Blob.size is the UTF-8 byte length, which is what the budget counts.
  if (blob.size > MAX_BATCH_BYTES) {
    // Oversized batches are dropped rather than split, to keep this simple.
    console.warn("RUM batch too large, dropped", blob.size);
    return;
  }
  // sendBeacon queues the request even while the page is being hidden or
  // unloaded. It returns false when the browser refuses to queue it.
  const queued = navigator.sendBeacon?.(ENDPOINT, blob);
  if (!queued) {
    fetch(ENDPOINT, { method: "POST", body: blob, keepalive: true }).catch(() => {
      /* Nothing useful to do while the page is going away. */
    });
  }
}

if (sampled) {
  onTTFB(enqueue);
  onFCP(enqueue);
  onLCP(enqueue);
  onINP(enqueue);
  onCLS(enqueue);

  // "hidden" covers tab switches, app switches, closing and navigating away.
  addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") flush();
  });
  // pagehide is a fallback for browsers that do not fire visibilitychange
  // on unload. Never use "unload": it disables the back/forward cache.
  addEventListener("pagehide", flush);
}

Load the module after your critical code, for example with import("./rum.js") in a requestIdleCallback or after the load event. The library observes with buffered: true, so it still receives entries that were emitted before it loaded.

The swVersion dimension is the easiest way to tie a performance regression to a service worker release. The simplest way to set it: the build that generates the worker also writes data-sw-version="…" into the <html> element of every HTML file it precaches, so a navigation served from the cache carries the version of the worker that cached it, without any runtime rewriting of responses. Pages served from the network can carry the server's current build ID the same way. Recording the version is what lets you answer "did the new caching strategy help?" with a split by version instead of a before/after guess.

A minimal collector for the beacons (Node.js)
server/rum-collector.mjs
import { createServer } from "node:http";
import { appendFile } from "node:fs/promises";

const MAX_BODY = 64 * 1024;

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/rum") {
    res.writeHead(404).end();
    return;
  }
  let size = 0;
  const chunks = [];
  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BODY) {
      res.writeHead(413).end();
      return;
    }
    chunks.push(chunk);
  }
  try {
    const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
    const receivedAt = new Date().toISOString();
    const ua = req.headers["user-agent"] ?? "";
    // One line per metric keeps later aggregation (p75 per dimension) simple.
    const lines = payload.metrics
      .map((m) => JSON.stringify({ receivedAt, ua, ...payload.context, ...m }))
      .join("\n");
    await appendFile("rum.ndjson", lines + "\n");
    res.writeHead(204).end();
  } catch {
    res.writeHead(400).end();
  }
}).listen(8080);

In production you would write to a queue or analytics store instead of a file, and aggregate p75 per metric, per URL pattern, per device class and per displayMode / swNavigation combination. See Analytics for PWAs.

Soft navigations in app-shell PWAs

A single-page PWA historically reported one LCP (the first route), one CLS and one INP for the entire session, however many routes the user visited. Chrome 151 enabled soft navigation measurement by default: when a user interaction changes the URL and results in a visible paint, Chrome emits a soft-navigation entry (PerformanceSoftNavigation) and interaction-contentful-paint entries from which a per-route LCP can be derived. web-vitals 6 exposes this with reportSoftNavs: true:

src/rum-soft-navs.js
import { onCLS, onINP, onLCP } from "web-vitals/attribution";

// Report per-route metrics where supported (Chromium 151+). In other browsers
// the option is ignored and you get classic per-document metrics.
for (const on of [onLCP, onINP, onCLS]) {
  on(
    (metric) => {
      // metric.navigationType is "soft-navigation" for route changes, and
      // metric.navigationURL is the route the metric belongs to.
      enqueueSoftNav(metric); // your batching function, as enqueue() in rum.js
    },
    { reportSoftNavs: true },
  );
}

Three caveats from the library's documentation: TTFB is reported as 0 for soft navigations; FCP and LCP only consider elements painted after the soft navigation (content that stays on screen, like your shell header, never counts); and INP and CLS are reset at every soft navigation, so the values attributed to the landing URL only cover the time until the first route change. Numbers collected with the option are therefore not comparable with historical per-document data: tag beacons with whether reportSoftNavs was on, and compare like with like. Chrome's documentation states that how soft navigations will appear in CrUX is still to be determined, so for now this data is for your own RUM only.

Field data versus lab data

Field (RUM, CrUX) Lab (Lighthouse, WebPageTest, DevTools)
Who Real users, real devices, real networks One simulated or throttled device
Service worker state Whatever users have: cold and warm workers, first and repeat visits, stale caches Usually a fresh profile: no worker, empty caches, unless you configure otherwise
INP Measured from real interactions Not measurable in a page-load audit; Total Blocking Time is the lab proxy. Interaction flows (Lighthouse timespan mode, DevTools recordings) can measure it for scripted interactions
CLS Whole page lifetime, including post-load shifts Usually only shifts during load
Use it to Know whether you have a problem and for whom Reproduce, debug and prevent regressions

The lab's default is the single biggest source of confusion for PWA teams. Lighthouse resets storage before an audit by default (the DevTools panel's Clear storage option; the CLI's --disable-storage-reset flag turns the reset off), so a standard run measures a first visit without a service worker. That is a legitimate and important scenario, but it tells you nothing about the cached path most of your returning users take. To measure a repeat visit in the lab, load the page once, wait for the worker to activate and precache, then run the audit without clearing storage, and run it twice: once with the worker stopped (cold start) and once warm. See Measuring Performance and Lighthouse & Auditing.

CrUX coverage caveats for PWAs

The Chrome User Experience Report is the dataset behind PageSpeed Insights' field data, Search Console's Core Web Vitals report and the CrUX API and BigQuery tables. Its data is a 28-day rolling window, updated daily in the API. Its methodology limits what it can tell you about a PWA:

  • Chrome only, and only some Chrome users. Users must have usage statistics reporting and history sync enabled, and no sync passphrase. Supported platforms are desktop Chrome (Windows, macOS, ChromeOS, Linux) and Android Chrome, including apps using Custom Tabs and WebAPKs, which means installed PWAs on Android and Trusted Web Activities are covered. Chrome on iOS, Android WebView apps and other Chromium browsers such as Edge are excluded. Every user of your PWA on an iPhone, whatever browser they use, is absent.
  • Only public, indexable pages. Pages must return HTTP 200 and must not carry noindex (header or meta). Many PWA routes, the logged-in app itself, are served with noindex or live behind authentication and will never have page-level CrUX data; at best they contribute to origin-level data.
  • Only sufficiently popular pages and origins. Below an undisclosed traffic threshold, there is no data at all. Low-traffic internal PWAs usually have none.
  • No soft navigations yet. An SPA-style PWA's CrUX data describes hard navigations (usually the first page of each visit) plus the INP and CLS of whole sessions attributed to that first URL.
  • iframes roll up. Content in iframes contributes to the top-level page's metrics, even though JavaScript cannot see inside cross-origin iframes. That is one reason RUM and CrUX values differ.
  • Aggregated by form factor. PHONE, TABLET and DESKTOP are separate distributions; check the one your users actually use.

CrUX also publishes a navigation_types distribution (navigate, reload, back-forward, back-forward cache, prerender, restore, and so on), useful for seeing how much of your traffic is bfcache restores, and LCP subpart metrics such as largest_contentful_paint_image_resource_load_delay. What it does not publish is any service-worker dimension: CrUX cannot tell you whether a slow LCP came from cold-start worker navigations. Only your own RUM can.

Treat CrUX as the scoreboard and RUM as the diagnosis

Use CrUX (through PageSpeed Insights or the CrUX API) to know whether the origin passes and how it trends over 28 days. Use your own RUM, segmented by swNavigation, displayMode, navigation type and device, to decide what to change. The two will not match exactly, and they do not need to.

Back/forward cache and Core Web Vitals

The back/forward cache (bfcache) keeps a fully loaded page in memory when the user navigates away, and restores it instantly on Back or Forward. web.dev reports that 1 in 10 navigations on desktop and 1 in 5 on mobile are back or forward navigations, so bfcache eligibility is one of the largest single wins available to any site, and it is not something a service worker can replicate: a service worker can serve cached bytes quickly, but the page still has to be parsed, executed and rendered again.

How restores are measured:

  • A bfcache restore is treated as a new page visit. The pageshow event fires with event.persisted === true.
  • LCP for a restore is the time from pageshow to the next frame, which is near-instant. INP and CLS are reset to 0 and measured afresh for the restored visit.
  • The web-vitals library does all of this automatically and reports a new metric instance with a new id and navigationType: "back-forward-cache".

What evicts or blocks a PWA from the bfcache

Several service worker operations prevent restores in Chrome. The Chrome DevTools Protocol lists them among the BackForwardCacheNotRestoredReason values:

Reason Caused by How to avoid it
ServiceWorkerClaim A new worker calling clients.claim() while the page sits in the bfcache Only claim when you need to; most apps do not need claim() after the first install
ServiceWorkerPostMessage The worker calling postMessage() to a client that is in the bfcache Do not push state to every client you once saw; message the client that asked (event.source), and let restored pages request fresh state on pageshow. A BroadcastChannel message is no escape: it evicts through BroadcastChannelOnMessage
ServiceWorkerVersionActivation A new worker version activating and taking control of the cached page Expected occasionally after deployments; no action needed
ServiceWorkerUnregistration The registration being unregistered Only unregister deliberately (kill switch)
UnloadHandler An unload listener in the page or a frame Never use unload; use pagehide or visibilitychange
BroadcastChannel / BroadcastChannelOnMessage An open BroadcastChannel, or a message arriving while cached Close channels in pagehide, reopen in pageshow
IndexedDBEvent, WebLocks Open IndexedDB connections with pending version changes, held Web Locks Close database connections in pagehide if other tabs may upgrade the schema
MainResourceHasCacheControlNoStore Cache-Control: no-store on the document See below

Cache-Control: no-store used to make a page ineligible in Chrome. Chrome rolled out bfcache for no-store pages gradually from Chrome 116 and reached 100% of users over March and April 2025. Such pages are evicted if cookies change, if they use WebSocket, WebTransport or WebRTC, or if a fetch/XHR returns a no-store response, and they are kept for at most 3 minutes. Enterprises can disable the behavior with the AllowBackForwardCacheForCacheControlNoStorePageEnabled policy.

Diagnosing bfcache misses in the field

PerformanceNavigationTiming.notRestoredReasons (Chromium) tells you, on a back/forward navigation that was not served from the bfcache, why it was not. Chrome's documentation says it shipped from Chrome 123 with a gradual rollout; MDN's compatibility data lists Chrome 125.

bfcache-telemetry.js
// Report bfcache restores, and why a back/forward navigation missed the cache.
addEventListener("pageshow", (event) => {
  if (event.persisted) {
    report({ type: "bfcache-restore" });
  }
});

const nav = performance.getEntriesByType("navigation")[0];
if (nav?.type === "back_forward" && nav.notRestoredReasons) {
  const collect = (node) => [
    ...node.reasons.map((r) => (typeof r === "string" ? r : r.reason)),
    ...(node.children ?? []).flatMap(collect),
  ];
  report({ type: "bfcache-miss", reasons: collect(nav.notRestoredReasons) });
}

function report(data) {
  navigator.sendBeacon("/rum/bfcache", JSON.stringify(data));
}

In DevTools, Application > Back/forward cache runs an eligibility test and lists blocking reasons for the current page. Test your installed PWA window too: back navigation inside a standalone window is still a history traversal and still uses the bfcache.

Browser support

Support data as of September 2026. For live data see MDN's compatibility tables for LargestContentfulPaint, PerformanceEventTiming and LayoutShift, or caniuse.com.

API (metric) Chrome / Edge Firefox Safari (macOS, iOS)
Paint Timing, first-contentful-paint (FCP) ✅ 60 ✅ 84 ✅ 14.1 (iOS 14.5)
Navigation Timing Level 2 (TTFB) ✅ 57 ✅ 58 ✅ 15 (iOS 15.1)
LargestContentfulPaint (LCP) ✅ 77 ✅ 122 ✅ 26.2
PerformanceEventTiming ✅ 76 ✅ 89 ✅ 26.2
interactionId (needed for INP) ✅ 96 ✅ 144 ✅ 26.2
performance.interactionCount ✅ 144 ✅ 144 ✅ 26.2
LayoutShift (CLS) ✅ 77 ❌ ❌
Long Animation Frames (INP attribution) ✅ 123 ❌ ❌
activationStart (prerender) ✅ 108 ❌ ❌
notRestoredReasons ⚠️ 123–125 ❌ ❌
Soft navigations (PerformanceSoftNavigation) ✅ 151 ❌ ❌

⚠️ Chrome's documentation gives Chrome 123 with a gradual rollout for notRestoredReasons; MDN lists 125. Edge, Opera and Samsung Internet follow their Chromium version.

Common pitfalls

  1. Optimizing only the cached path. p75 includes first visits. If a third or more of loads are first visits, they decide your score.
  2. Network-first navigations without navigation preload. Every cold start pays worker boot-up plus the full round trip, which makes LCP worse than having no worker at all.
  3. Registering the service worker before the page's own LCP resources have loaded. Precaching competes for bandwidth on the first visit.
  4. A pass-through fetch handler that adds dispatch overhead to every request without caching anything.
  5. An app shell for content pages. A fast FCP with a skeleton, then a slow LCP after a client-side API call. Serve rendered HTML for content, or stream it.
  6. Measuring in the lab with a fresh profile and concluding the service worker "does nothing". Lighthouse clears storage by default.
  7. Using unload to send analytics. It blocks the bfcache and is unreliable on mobile. Use visibilitychange.
  8. Calling clients.claim() and broadcasting postMessage() to all clients on every activation. Both evict pages from Chrome's bfcache.
  9. Late-inserted install or update banners in the document flow. They cause CLS outside the 500 ms input window. Use fixed-position overlays.
  10. Reading CLS and INP in load or on a timer. Both keep changing for the page's lifetime; report on visibilitychange.
  11. Comparing web-vitals 4 data with web-vitals 6 data without a marker. Metric definitions and library behavior change; store the library version with each beacon.
  12. Assuming CrUX covers your installed users. It covers Android WebAPKs and Custom Tabs, but not iOS, not WebView-based wrappers, and not noindex routes.

Debugging

  • DevTools Performance panel. The panel shows live LCP, CLS and INP values for the current page as you interact, with the LCP element and the interactions that produced the INP. Record a trace to see the LCP subparts, long tasks, and layout shift clusters.
  • Test the cold worker. In Application > Service workers, click Stop, or open chrome://serviceworker-internals, before each measurement. A warm worker hides start-up cost.
  • Check which requests went through the worker. In the Network panel, responses served by a worker show "(ServiceWorker)" in the Size column; requests the worker made itself appear with a gear icon.
  • Compare with and without the worker. Application > Service workers > Bypass for network shows what your page costs without the worker's cache.
  • Throttle realistically. Use CPU throttling (4× or more) for INP work; a desktop CPU hides almost every long task a mid-range phone suffers.
  • Log attribution in development. Load web-vitals/attribution with reportAllChanges: true and log to the console; turn it off in production.
  • Check bfcache eligibility in Application > Back/forward cache for both browser tabs and the installed app window.

Further reading

On this site

External references