Skip to content

Lighthouse and Auditing PWAs

Lighthouse is Google's open-source auditing tool for web pages. It launched in 2016 as a PWA checker and carried a dedicated PWA category for eight years. Lighthouse 12.0, released on April 22, 2024, removed that category, because its checks were tied to Chrome's installability criteria, and Chrome had just relaxed those criteria. Lighthouse is still the right tool for performance, accessibility, SEO and best practices on a PWA. PWA-specific auditing now takes a combination: Lighthouse user flows for repeat and offline visits, a few custom audits, Chromium's own installability check, end-to-end tests, and tools such as PWABuilder's report card. This page covers all of them, with configurations tested against Lighthouse 13.5.0 and Lighthouse CI 0.15.1 in September 2026.

Key takeaways

  • Lighthouse 12.0 (April 22, 2024; DevTools in Chrome 126) removed the PWA category and its audits (installable-manifest, splash-screen, themed-omnibox, maskable-icon, content-width and the manual checks), along with the manifest and installability gatherers. Lighthouse 13 rejects --only-categories=pwa with "unrecognized category".
  • The reason was Chrome's December 2023 change to its installability criteria (no fetch handler required for installing from the browser menu since Chrome 108 on mobile and 112 on desktop). The category's checks were a copy of those criteria, and it had rewarded empty fetch handlers that hurt performance.
  • Lighthouse clears service workers and Cache Storage before each navigation unless you set disableStorageReset. Audit a PWA's repeat visit with a user flow, or with a Lighthouse CI puppeteerScript that warms the worker.
  • Lighthouse overrides network emulation at the start of each navigation, so you can't audit "offline" by toggling DevTools-style offline mode. Make the origin unreachable at the server instead.
  • Custom audits need a custom config, not a plugin, because only configs can add gatherers. A gatherer that calls Page.getAppManifest and Page.getInstallabilityErrors restores the most useful part of the old category in about 150 lines.
  • Lighthouse CI 0.15.1 bundles Lighthouse 12.6.1. You can run it with Lighthouse 13 through an npm overrides entry, but its presets then fail on audits that 13.0 removed. Write explicit assertions.

A short history of Lighthouse's PWA audits

Lighthouse's first releases, in mid-2016, checked a page against the then-new "progressive web app" checklist and were almost entirely PWA audits. Performance, accessibility, best practices and SEO were added later. The PWA category became one category among several, and its content followed Chrome's changing definition of an installable app.

Version (date) PWA-relevant change
1.x (July–October 2016) Earliest changelog entries; audits focused on PWA checks such as a registered service worker, a 200 response while offline, and manifest completeness
2.0 (May 19, 2017) The nine manifest audits collapsed into three; a "time to interactive under 10 s" audit added for PWAs
4.0 (January 16, 2019) New PWA category layout with badges instead of a numeric score: Fast and reliable, Installable, PWA Optimized. webapp-install-banner renamed installable-manifest, and the offline check split into offline-start-url
6.0 (May 19, 2020) New maskable-icon audit. installable-manifest requires fetchable icons and lowers the minimum icon size from 192 px to 144 px
7.0 (December 16, 2020; Chrome 89 DevTools) Installable group now "powered entirely by the capability checks that enable Chrome's installable criteria". works-offline, offline-start-url, load-fast-enough-for-pwa and without-javascript removed; service-worker moved to PWA Optimized
10.0 (February 9, 2023) apple-touch-icon audit removed
11.0 (August 3, 2023) service-worker audit removed
11.5.0 (January 23, 2024) PWA category shows a deprecation warning
12.0 (April 22, 2024; Chrome 126 DevTools) PWA category removed (GoogleChrome/lighthouse#15455, merged April 10, 2024), along with the service-worker gatherer and Lighthouse's built-in budgets
13.0 (October 10, 2025; Chrome 143) Performance audits replaced by "insights"; unknown categories in onlyCategories now throw; Node 22.19 or later required
13.3.0 (May 7, 2026) New Agentic Browsing category in the default config
13.5.0 (September 17, 2026) Current release as of this writing; expected in Chrome 156 DevTools

Dates come from the Lighthouse changelog and the pre-10.0 changelog in the same repository.

The PWA category at its largest (6.x) and just before its removal (11.7) shows how much it had already shrunk:

Audit Lighthouse 6.4 group Lighthouse 11.7 group What it checked
load-fast-enough-for-pwa Fast and reliable (removed in 7.0) Interactive fast enough on a simulated mobile network
works-offline Fast and reliable (removed in 7.0) Current URL responds 200 offline
offline-start-url Fast and reliable (removed in 7.0) start_url responds 200 offline
is-on-https Installable (moved out in 7.0; now Best Practices) HTTPS, no mixed content
service-worker Installable (removed in 11.0) A worker controls the page and start_url
installable-manifest Installable Installable Chrome's installability errors
redirects-http PWA Optimized (gone from the category by 9.x; brought back in 12.0 as a passive Best Practices check) HTTP redirects to HTTPS
splash-screen PWA Optimized PWA Optimized Manifest has name, background_color, theme_color and a 512 px icon
themed-omnibox PWA Optimized PWA Optimized theme_color and <meta name="theme-color">
content-width PWA Optimized PWA Optimized Content fits the viewport
viewport PWA Optimized PWA Optimized <meta name="viewport"> with width or initial-scale
without-javascript PWA Optimized (removed in 7.0) Some content without JavaScript
apple-touch-icon PWA Optimized (removed in 10.0) <link rel="apple-touch-icon"> present
maskable-icon PWA Optimized PWA Optimized At least one purpose: "maskable" icon
pwa-cross-browser, pwa-page-transitions, pwa-each-page-has-url Manual Manual Checklist items Lighthouse never verified

The old works-offline audit worked by loading the page a second time with network emulation set to offline, the one thing that is now awkward to reproduce (see Auditing offline behavior).

Why the PWA category was removed

The removal followed a change in Chrome, not a change of heart about PWAs. On December 5, 2023, Chrome published "Updates to installability criteria":

  • The requirement for a service worker with a fetch handler was dropped for installation from the browser menu, starting with Chrome 108 on mobile and Chrome 112 on desktop. At that time the automatic install prompt still required a fetch handler; current Chromium's install promotion has no service worker check. For sites without their own offline page, Chrome shows a default offline page.
  • Chrome's rationale: the service worker check "was meant as a proxy for detecting sites with some offline experience, but sites added service workers with empty fetch handlers to satisfy the criteria. This hurts web performance instead of improving the experience." An empty fetch handler forces the browser to start the worker for every request and then fall back to the network, adding latency with no benefit.
  • Because "the Lighthouse PWA checks are directly associated with the installability criteria", the post announced that the category would be removed.

Three underlying problems made the category hard to keep:

  1. It duplicated Chrome's own check. Since 7.0, installable-manifest called Chrome's installability evaluator. Once Chrome's criteria no longer described a "good PWA", neither did the audit.
  2. A green badge was misleading. Teams treated the PWA badge as a quality certificate, but it said nothing about offline UX, update handling, or behavior on Safari and Firefox, which have different install models (Installation by Platform).
  3. It encouraged the anti-pattern it measured. The empty-fetch-handler workaround existed largely to pass this audit and the old install criteria.

History & Evolution covers the wider context. Chrome kept supporting installed web apps, and in Chrome 128 it added "Install page as app" for any site. What changed is that "installable" stopped being a checklist that a tool could certify.

What changed in Lighthouse 12 and 13 for PWA auditing

The removal wasn't limited to one category in the report. Several related pieces went with it, and some of them matter for custom setups:

Change Version Consequence
PWA category, its audits and groups (pwa-installable, pwa-optimized) removed 12.0 Scripts that read lhr.categories.pwa get undefined. Rendering of old reports that contain the category still works
WebAppManifest and InstallabilityErrors gatherers and the ManifestValues computed artifact removed 12.0 Custom audits can't requiredArtifacts: ['WebAppManifest']; write your own gatherer (below)
service-worker gatherer removed (the audit went in 11.0) 12.0 No built-in artifact describes service worker registrations
Built-in budgets (budgets setting, --budget-path, performance-budget and timing-budget audits) removed 12.0 Use Lighthouse CI assertions for budgets (see Budgets)
viewport and font-size moved from SEO to Best Practices 12.0 viewport survived the PWA removal
resource-summary reintroduced as a hidden audit 11.4.0 Request counts and transfer sizes per resource type, used by LHCI budget assertions
Performance audits replaced by insights; font-size, offscreen-images, no-document-write, uses-passive-event-listeners, third-party-facades, preload-fonts, uses-rel-preload, first-meaningful-paint removed 13.0 CI assertions on those IDs fail; see the Lighthouse 13 announcement
onlyCategories with an unknown ID throws 13.0 lighthouse --only-categories=pwa fails with "unrecognized category in 'onlyCategories': pwa" (verified with 13.5.0)
CacheContents artifact removed 13.0 It listed Cache Storage URLs; read caches in your own gatherer if you need them
Node 22.19 or later required 13.0 Update CI images
Agentic Browsing category added 13.2–13.3 Default reports now have five categories: performance, accessibility, best practices, SEO and agentic browsing

In short, current Lighthouse measures a PWA's pages like any other page. Everything that made the PWA a PWA (manifest, worker, offline, install) is out of scope unless you add it back.

What to use instead

Each thing the old category measured now has a better tool, and most of these tools test more than the category ever did:

Old audit or question Use now Where
installable-manifest Chromium's own check via Page.getInstallabilityErrors: DevTools Application → Manifest → Installability, or scripted in CI Installability Criteria, Automated Testing
splash-screen, themed-omnibox, maskable-icon Manifest assertions in your test suite; DevTools Manifest pane previews; PWABuilder report card Icons & Maskable Icons, Splash Screens & Theming
service-worker E2E test that asserts registration and control Automated Testing
works-offline, offline-start-url E2E offline tests in every engine; a Lighthouse user flow with the origin unreachable Automated Testing, below
load-fast-enough-for-pwa Performance category, Core Web Vitals field data, repeat-visit user flows Core Web Vitals, Measuring Performance
content-width, viewport Best Practices (viewport), the viewport-insight performance insight, responsive tests Responsive & Adaptive Design
"Is it a good PWA?" A checklist of behaviors you test yourself Production Checklist

Lighthouse is still worth running on a PWA. The performance, accessibility and best-practices categories apply directly, and the rest of this page shows how to point them at the states that matter for an installed app: warm, controlled and offline.

How Lighthouse treats service workers

Before every navigation, Lighthouse resets storage for the origin under test with Storage.clearDataForOrigin. The default clearStorageTypes setting is ['file_systems', 'shader_cache', 'service_workers', 'cache_storage']. Every default Lighthouse run is therefore a first visit. The page loads from the network, registers the worker, and the worker installs in the background while Lighthouse measures. Two consequences:

  • Your worker's install work (precaching) competes with the page load being measured. A heavy precache list shows up as bandwidth contention in LCP and as main-thread or network activity in the trace. That is useful to know, since real first visits pay the same cost.
  • The repeat visit, which is where a service worker pays off, is never measured by default.

To measure a controlled load, disable the reset and load the page twice:

  • CLI: lighthouse <url> --disable-storage-reset, after warming the worker in the same Chrome profile (--port to reuse a browser you have already navigated), or with Lighthouse CI's puppeteerScript.
  • Node API: pass disableStorageReset: true in the flags.
  • User flows: only the first navigation step resets storage by default. UserFlow sets disableStorageReset: true on every later navigation step unless you pass a value yourself, so a second flow.navigate() to the same URL is already a warm load. Pass disableStorageReset: false on a later step if you want another cold load.
  • DevTools: clear the Clear storage checkbox in the Lighthouse panel's settings and run it on a page you have already visited.

In a warm run, the network-requests audit shows the difference directly. Responses served by the worker from Cache Storage report a transferSize of 0. In the test app used on this page, the document, app.js and the manifest all dropped to zero bytes on the warm run.

User flows that start with an interaction-initiated navigation (a callback instead of a URL) never clear storage, because Lighthouse doesn't know which URL is about to load. The user flows documentation notes this explicitly.

Lighthouse user flows for PWAs

Since Lighthouse 9.6 (2022), the Node API can audit a sequence of steps (a user flow) in three modes. Each category declares the modes it supports:

Mode Analyzes Categories available (13.5) PWA use
Navigation A full page load, cold or warm All five Cold vs warm (controlled) loads, offline loads
Timespan An arbitrary period with interactions Performance (no overall score, no LCP) and best practices SPA route changes served from cache, update prompts, install UI
Snapshot The DOM in its current state Accessibility, best practices, SEO, agentic browsing, and a few DOM-based performance checks (no metrics) The offline fallback page, update banners, dialogs

The flow below audits the four states a PWA page goes through: a cold first visit, a warm visit controlled by the worker, a snapshot of the controlled state, and a visit with the origin unreachable. It uses the same test server as Automated Testing, whose setNetworkDown() drops every connection.

lighthouse/pwa-flow.mjs
// Usage: node lighthouse/pwa-flow.mjs   (Node 22.19+, "type": "module")
import { writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { startFlow } from 'lighthouse';
import pwaConfig from './pwa-config.js';
import { startServer } from '../e2e/support/server.mjs';

const app = await startServer({ root: 'dist' });
const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH || undefined });

try {
  const page = await browser.newPage();
  const flow = await startFlow(page, { name: 'PWA offline journey', config: pwaConfig });

  // 1. First visit: Lighthouse clears SW + Cache Storage, then loads.
  await flow.navigate(`${app.url}/`, { name: 'Cold load (storage reset)' });

  // 2. Repeat visit: keep the worker installed by step 1. Later navigation steps
  //    already default to disableStorageReset: true; being explicit documents intent.
  await flow.navigate(`${app.url}/`, { name: 'Warm load (SW controlled)', disableStorageReset: true });

  // 3. Audit the controlled page as it is (a11y, best practices, custom PWA checks).
  await flow.snapshot({ name: 'Controlled state' });

  // 4. Origin unreachable: every request that leaves the worker fails.
  app.setNetworkDown(true);
  await flow.navigate(`${app.url}/`, { name: 'Server unreachable', disableStorageReset: true });
  app.setNetworkDown(false);

  const result = await flow.createFlowResult();
  for (const step of result.steps) {
    const scores = Object.entries(step.lhr.categories)
      .map(([id, c]) => `${id}=${c.score ?? 'n/a'}`).join(' ');
    console.log(`${step.name} [${step.lhr.gatherMode}] ${scores}`);
  }

  await writeFile('pwa-flow-report.html', await flow.generateReport());
  await writeFile('pwa-flow-result.json', JSON.stringify(result, null, 2));
} finally {
  await browser.close();
  await app.close();
}

Step options are Lighthouse flags plus a name, so any flag (disableStorageReset, formFactor, throttlingMethod, onlyCategories) can differ per step. startFlow(page, { config, flags, name }) sets defaults for all steps. For desktop, pass config: desktopConfig (exported by lighthouse) and set flags: { screenEmulation: { disabled: true } } if Puppeteer already sets the viewport.

On the test app, this flow produced what you would expect from a correctly built PWA, plus one finding that isn't obvious. In the Server unreachable step, the page rendered from the worker's cache and scored normally, but the custom installability audit (next section) failed with no-acceptable-icon (minimum-icon-size-in-pixels=144). Chromium's installability check downloads the manifest icons, the worker had precached the manifest but not the icons, and the icon fetch failed. If the installability check should pass while the network is down, precache the icons your manifest declares.

Auditing offline behavior

The obvious way to audit offline, emulating offline in the browser before a navigation step, doesn't work. At the start of each navigation Lighthouse applies its own network settings. With the default simulated throttling, it clears network emulation by sending Network.emulateNetworkConditions with offline: false. With throttlingMethod: 'devtools' it applies throttling, again with offline: false. Whatever offline state you set is overwritten before the page loads. Old Lighthouse versions had a dedicated offline pass for works-offline, and that pass is gone.

What works:

  1. Make the origin unreachable while the worker stays installed, as in step 4 above. Drop connections in a test server, stop the preview server, or point the host at a closed port through a proxy. navigator.onLine stays true, so this tests your worker's fallback logic, not UI code that listens for offline events.
  2. Block specific URLs with the blockedUrlPatterns setting to simulate a failing API while the shell loads normally. Check in the report that the blocked requests actually failed, because interaction between request blocking and requests issued by the worker is not documented.
  3. Assert offline behavior in E2E tests and use Lighthouse only for the quality of the offline experience: accessibility of the offline page, layout shifts when fallback content swaps in, and performance of a cache-only load.

A snapshot of the offline fallback page is especially valuable. It's the page nobody looks at during development, and accessibility problems on it (missing headings, low contrast, no way back) often go unnoticed (Offline UX & Fallbacks).

Writing custom PWA audits

Lighthouse has two extension mechanisms, and only one can collect new data:

Plugin (lighthouse-plugin-*) Custom config
Add audits and a category ✅ ✅
Add gatherers (collect new data from the page) ❌ ✅
Distribution npm package, --plugins=lighthouse-plugin-x A config file in your repo, --config-path or the Node API

Plugins are limited to the stable public artifacts (LinkElements, MetaElements, DevtoolsLog and others). None of them describes the manifest or service worker state any more, so PWA checks need a custom config with a gatherer. The gatherer below collects three signals straight from Chromium. The Gatherer API has been stable since Lighthouse 10, and this code runs unchanged on 12.6.1 and 13.5.0.

lighthouse/pwa-gatherer.js
import { Gatherer } from 'lighthouse';

/**
 * Collects what the removed PWA category used to look at, straight from
 * Chromium: the parsed manifest, Chromium's own installability verdict, and
 * the page's service worker state.
 */
class PwaSignals extends Gatherer {
  meta = {
    // Snapshot lets you audit a page that is already SW-controlled.
    supportedModes: ['navigation', 'snapshot'],
  };

  async getArtifact(context) {
    const session = context.driver.defaultSession;

    // Page.getInstallabilityErrors is marked experimental in the protocol, and
    // getAppManifest rejects when no page is loaded; tolerate both failures.
    const manifest = await session.sendCommand('Page.getAppManifest').catch((err) => ({ error: err.message }));
    const installability = await session.sendCommand('Page.getInstallabilityErrors')
      .catch((err) => ({ installabilityErrors: [], error: err.message }));

    const serviceWorker = await context.driver.executionContext.evaluate(
      async function collectServiceWorkerState() {
        if (!('serviceWorker' in navigator)) return { supported: false };
        const registration = await navigator.serviceWorker.getRegistration();
        return {
          supported: true,
          registered: Boolean(registration),
          scope: registration?.scope ?? null,
          activeState: registration?.active?.state ?? null,
          controlled: navigator.serviceWorker.controller !== null,
        };
      },
      { args: [] },
    );

    return {
      manifestUrl: manifest.url ?? null,
      manifestErrors: manifest.errors ?? [],
      installabilityErrors: installability.installabilityErrors ?? [],
      serviceWorker,
    };
  }
}

export default PwaSignals;

executionContext.evaluate() serializes the function with toString() and runs it in the page, so the function must be self-contained. It can't close over variables from the gatherer module. Pass values through args.

lighthouse/installable-audit.js
import { Audit } from 'lighthouse';

class InstallableAudit extends Audit {
  static get meta() {
    return {
      id: 'pwa-installable',
      title: 'Chromium considers the app installable',
      failureTitle: 'Chromium reports installability errors',
      description: 'Uses Page.getInstallabilityErrors, the same check behind the DevTools Manifest pane.',
      supportedModes: ['navigation', 'snapshot'],
      requiredArtifacts: ['PwaSignals'],
    };
  }

  static audit(artifacts) {
    const { installabilityErrors, manifestUrl, manifestErrors } = artifacts.PwaSignals;
    const items = [
      ...installabilityErrors.map((e) => ({
        source: 'installability',
        id: e.errorId,
        detail: e.errorArguments.map(({ name, value }) => `${name}=${value}`).join(', '),
      })),
      ...manifestErrors.map((e) => ({
        source: 'manifest parser',
        id: e.critical ? 'critical' : 'warning',
        detail: e.message,
      })),
    ];

    const headings = [
      { key: 'source', valueType: 'text', label: 'Source' },
      { key: 'id', valueType: 'code', label: 'Error' },
      { key: 'detail', valueType: 'text', label: 'Detail' },
    ];

    return {
      score: installabilityErrors.length === 0 && manifestUrl ? 1 : 0,
      displayValue: manifestUrl ? undefined : 'No manifest linked',
      details: Audit.makeTableDetails(headings, items),
    };
  }
}

export default InstallableAudit;
lighthouse/service-worker-audit.js
import { Audit } from 'lighthouse';

class ServiceWorkerAudit extends Audit {
  static get meta() {
    return {
      id: 'pwa-service-worker',
      title: 'A service worker is registered and activated',
      failureTitle: 'No activated service worker',
      description: 'Checks navigator.serviceWorker.getRegistration() after load. '
        + 'Lighthouse clears service workers before a navigation unless disableStorageReset is set.',
      supportedModes: ['navigation', 'snapshot'],
      requiredArtifacts: ['PwaSignals'],
    };
  }

  static audit(artifacts) {
    const sw = artifacts.PwaSignals.serviceWorker;
    const ok = sw.supported && sw.registered && sw.activeState === 'activated';
    return {
      score: ok ? 1 : 0,
      displayValue: sw.controlled ? 'Page is controlled' : 'Page is not controlled (yet)',
      details: Audit.makeTableDetails(
        [
          { key: 'scope', valueType: 'url', label: 'Scope' },
          { key: 'activeState', valueType: 'text', label: 'Active worker state' },
          { key: 'controlled', valueType: 'text', label: 'Controls this page' },
        ],
        sw.registered ? [{ scope: sw.scope, activeState: sw.activeState, controlled: String(sw.controlled) }] : [],
      ),
    };
  }
}

export default ServiceWorkerAudit;
lighthouse/pwa-config.js
export default {
  extends: 'lighthouse:default',
  artifacts: [
    { id: 'PwaSignals', gatherer: './lighthouse/pwa-gatherer.js' },
  ],
  audits: [
    './lighthouse/installable-audit.js',
    './lighthouse/service-worker-audit.js',
  ],
  categories: {
    'pwa-custom': {
      title: 'PWA (custom checks)',
      description: 'Project-specific replacement for the PWA category removed in Lighthouse 12.',
      supportedModes: ['navigation', 'snapshot'],
      auditRefs: [
        { id: 'pwa-installable', weight: 1 },
        { id: 'pwa-service-worker', weight: 1 },
      ],
    },
  },
};

Details that matter when you adapt this:

  • Paths in artifacts[].gatherer and audits: when you pass a config object through the Node API or Lighthouse CI, relative paths resolved against the current working directory in these tests. Run Lighthouse from the project root, or build absolute paths from import.meta.url.
  • ES modules. Lighthouse 10 and later are ESM. The config, gatherer and audit files need "type": "module" in package.json or an .mjs extension. Without it, loading the config fails with "Unexpected token 'export'".
  • Installability and incognito. Lighthouse CLI and Lighthouse CI launch Chrome with a temporary normal profile, and Puppeteer's browser.newPage() uses the default context, so Page.getInstallabilityErrors works. In an incognito or off-the-record context it always returns in-incognito (Automated Testing).
  • Scoring. A category's score is the weighted mean of its audits. A binary audit weighted 1 in a two-audit category gives 0.5 on failure, which is what the Server unreachable step above reported. Weight the audits you consider blocking higher, or assert on the audit IDs themselves in CI rather than on the category score.
  • Snapshot vs navigation. In snapshot mode the gatherer sees the page as it is, which is the only way to audit "controlled" without a reload. In navigation mode with the default storage reset, controlled is usually still false at gather time on a first load, unless the worker calls clients.claim() quickly. The audit therefore checks activeState, not controlled.

Run it from the CLI with lighthouse <url> --config-path=./lighthouse/pwa-config.js, from the Node API as the flow above does, or from Lighthouse CI via settings.configPath.

Lighthouse CI

Lighthouse CI (LHCI) runs Lighthouse several times per URL, aggregates the results, asserts on them, and uploads reports. As of September 2026, @lhci/cli 0.15.1 (June 2025) is the latest release, and it depends on Lighthouse 12.6.1, not 13.

Configuration for a PWA

LHCI looks for lighthouserc.cjs, lighthouserc.js, lighthouserc.json or lighthouserc.yml (and dot-prefixed variants) in the working directory. In a "type": "module" project, use .cjs for a JavaScript config, because LHCI loads it with require().

lighthouserc.cjs
module.exports = {
  ci: {
    collect: {
      // LHCI serves this directory itself and rewrites the port in `url`.
      staticDistDir: './dist',
      url: ['http://localhost/', 'http://localhost/offline.html'],
      numberOfRuns: 3,
      // Warm the service worker before each URL's runs (see below).
      puppeteerScript: './lighthouse/warm-sw.cjs',
      settings: {
        configPath: './lighthouse/pwa-config.js',
        // Keep the worker installed by puppeteerScript: measures repeat visits.
        disableStorageReset: true,
      },
    },
    assert: {
      assertMatrix: [
        {
          // Real app pages: must be installable and controlled.
          matchingUrlPattern: '^http://localhost:\\d+/$',
          assertions: {
            // Binary checks must pass on every run, not just the best one.
            'pwa-installable': ['error', { minScore: 1, aggregationMethod: 'pessimistic' }],
            'pwa-service-worker': ['error', { minScore: 1, aggregationMethod: 'pessimistic' }],
            'categories:performance': ['error', { minScore: 0.9, aggregationMethod: 'median-run' }],
            'categories:accessibility': ['error', { minScore: 1 }],
            'largest-contentful-paint': ['error', { maxNumericValue: 2500, aggregationMethod: 'median' }],
            'cumulative-layout-shift': ['error', { maxNumericValue: 0.1, aggregationMethod: 'median' }],
            'resource-summary:script:size': ['error', { maxNumericValue: 170_000 }],
            'resource-summary:third-party:count': ['warn', { maxNumericValue: 5 }],
          },
        },
        {
          // The offline fallback is a standalone page: accessibility only.
          matchingUrlPattern: 'offline\\.html$',
          assertions: {
            'categories:accessibility': ['error', { minScore: 1 }],
          },
        },
      ],
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};
lighthouse/warm-sw.cjs
/**
 * LHCI puppeteerScript: runs once per URL before Lighthouse. Loads the page,
 * waits until the service worker controls it, and leaves it installed.
 * Pair with settings.disableStorageReset so Lighthouse does not wipe it.
 * @param {import('puppeteer').Browser} browser
 * @param {{ url: string }} context
 */
module.exports = async (browser, { url }) => {
  const page = await browser.newPage();
  try {
    await page.goto(url, { waitUntil: 'load' });
    await page.waitForFunction(
      async () => {
        const reg = await navigator.serviceWorker.getRegistration();
        return reg?.active?.state === 'activated' && navigator.serviceWorker.controller !== null;
      },
      { timeout: 15_000, polling: 100 },
    );
  } finally {
    await page.close();
  }
};

How the pieces fit:

  • puppeteerScript runs before the runs for each URL, in the same browser Lighthouse then uses. LHCI loads puppeteer, then falls back to puppeteer-core, from your project. Install one of them yourself. The script is loaded with require(), so it must be CommonJS. Puppeteer 25 is ESM-only, and LHCI's require('puppeteer') works because Node 22.12 and later can require() ES modules.
  • disableStorageReset: true keeps the worker the script installed. The report then describes a repeat visit, the case where your caching strategy is supposed to pay off. If you also want first-visit numbers, run a second LHCI configuration without the script and the flag.
  • Assertion levels are off, warn (printed, exit code 0) and error (non-zero exit). Options are minScore, maxNumericValue and maxLength. A bare level such as 'pwa-installable': 'error' implies minScore: 0.9. Every assertion also checks auditRan, which is why an assertion on a removed audit ID fails. aggregationMethod is one of optimistic (the default: the best value across runs), pessimistic (the worst), median (the median value of that audit) and median-run (every assertion reads one representative run: the run closest to the median First Contentful Paint and Time to Interactive). Use median or median-run for metrics, because optimistic hides regressions that show up in two of three runs, and use pessimistic for binary checks such as pwa-service-worker that must pass on every run.
  • assertMatrix can't be combined with top-level assertions, preset or budgetsFile. LHCI throws "Cannot use assertMatrix with other options".
  • Upload targets: temporary-public-storage (public URLs, deleted after a few days, stored on Google Cloud Storage), lhci (your own LHCI server, which also tracks history and diffs), or filesystem (outputDir, for processing results yourself).

Presets and Lighthouse 13

LHCI's presets are lighthouse:all, lighthouse:recommended and lighthouse:no-pwa. no-pwa is recommended with is-on-https and viewport turned off. That made sense when both lived in the PWA category, and the source now carries a "PWA doesn't exist anymore" TODO. With the bundled Lighthouse 12.6.1, lighthouse:recommended is still a reasonable starting point.

You can run LHCI with the current Lighthouse through npm's overrides, referencing your own direct dependency:

package.json (excerpt)
{
  "devDependencies": {
    "@lhci/cli": "0.15.1",
    "lighthouse": "13.5.0",
    "puppeteer": "^25.12.0"
  },
  "overrides": {
    "lighthouse": "$lighthouse"
  }
}

A plain "lighthouse": "13.5.0" override fails with EOVERRIDE ("conflicts with direct dependency"). The $lighthouse form tells npm to reuse the version of your direct dependency. With this in place, reports showed lighthouseVersion: "13.5.0" and the Agentic Browsing category. But lighthouse:recommended then fails more than a dozen assertions with auditRan errors, one for each audit that Lighthouse 13 removed or renamed (offscreen-images, font-size, uses-rel-preconnect, lcp-lazy-loaded and others). If you move to Lighthouse 13, drop the preset and write explicit assertions, as the configuration above does.

Budgets with Lighthouse CI

Lighthouse 12 removed its own budgets (the budgets setting, --budget-path, and the performance-budget and timing-budget audits). An LHCI configuration that sets settings.budgetPath and asserts performance-budget no longer produces anything to assert on. Two forms still work, because LHCI evaluates them itself:

  • Resource budgets as assertions: resource-summary:<type>:size and resource-summary:<type>:count, where <type> is document, script, stylesheet, image, media, font, other, third-party or total. They read the hidden resource-summary audit. Sizes are bytes of transfer size.
  • budgetsFile: a budget.json file, which LHCI converts into an assertMatrix at assert time. timings become metric assertions, resourceSizes (in kilobytes, multiplied by 1,024) and resourceCounts become resource-summary assertions, and path becomes a URL pattern. It can't be combined with other assertion options.

For a PWA, the budgets that matter most are the size of what the worker precaches and the size of the first load. Transfer sizes on a warm, disableStorageReset run are near zero for everything the worker serves from cache, so assert resource budgets on a cold run. Check the precache manifest size at build time (Precaching).

Running Lighthouse CI in GitHub Actions

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

jobs:
  lhci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run build
      - name: Run Lighthouse CI
        run: npx lhci autorun
        env:
          # Optional: the Lighthouse CI GitHub App token adds a status check per URL.
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

lhci autorun runs collect, assert and upload in sequence, and uses staticDistDir or startServerCommand from the configuration. GitHub-hosted Ubuntu runners include Google Chrome, which LHCI finds through chrome-launcher. Set CHROME_PATH to pin a specific build. Every configuration option can also be set through LHCI_-prefixed environment variables, which is how the GitHub App token is passed above. Run Lighthouse CI alongside the Playwright suite, not instead of it. LHCI tells you whether the pages are fast and accessible, and the E2E suite tells you whether the PWA works.

PWABuilder report card

PWABuilder, Microsoft's open-source PWA tool, analyzes a public URL and shows a report card at https://www.pwabuilder.com/reportcard?site=<url>. Its primary purpose is packaging a PWA for app stores (PWABuilder, Publishing to App Stores), so it grades what those packages need. Since the October 2022 redesign, the report card has six cards:

Card Shows
App preview Icon, name, URL, description, theme color, and a Retest button
Package for stores Store-ready packages for the platforms PWABuilder supports, plus test packages when requirements aren't met
Action items A to-do list: red stop signs block packaging, yellow yield signs are strongly recommended
Manifest Every member graded as required, recommended or optional, with an editor
Service worker Detection results and prebuilt workers you can download
Security HTTPS, a valid certificate, and no mixed content

Progress rings are red while a required item fails, yellow once all required items pass, and green when recommended items pass too. An App capabilities card also lists the advanced manifest members you use or could use (share target, file handlers, protocol handlers, widgets and others).

The analysis itself is Lighthouse-based. PWABuilder's backend (in the pwa-builder/PWABuilder repository, under apps/pwabuilder) runs Lighthouse, pinned to 12.8.2 in its node-scripts package as of September 2026, with custom gatherers and audits for the service worker, the raw manifest, HTTPS and offline support. Its offline audit passes when the start page returns 200 from the service worker. Its manifest rules are published separately as @pwabuilder/manifest-validation (Automated Testing shows how to run them in CI).

Strengths and limits of the report card:

  • It's the closest thing to the old PWA category, with a broader and more current manifest rule set, including members such as launch_handler, display_override, scope_extensions and edge_side_panel.
  • It audits from PWABuilder's servers, so only publicly reachable URLs can be tested. Use it on staging or production, not in pull-request CI.
  • Its "recommended" list reflects store packaging priorities. A missing iarc_rating_id or edge_side_panel isn't a defect for most web-only PWAs. Treat red items as blockers only if you intend to package.
  • It checks one URL, once. It doesn't test updates, multiple routes or offline navigation beyond the start page.

Auditing checklist for a PWA release

Combine the tools by what each does best:

  1. Every pull request: unit and E2E tests (Automated Testing), including offline, update and manifest checks. Lighthouse CI on the built dist/ with explicit assertions on cold and warm runs.
  2. Before a release: a Lighthouse user flow covering cold, warm, snapshot and origin-unreachable states on staging, with the custom PWA config. Read the flow report, not just the scores.
  3. After deploying to production: the PWABuilder report card for manifest and packaging readiness. DevTools' Manifest pane on real devices (Browser DevTools). Field data for Core Web Vitals (Measuring Performance).
  4. Per platform: manual install and launch checks on iOS, Android and desktop, which no automated tool covers (Installation by Platform).

Common pitfalls

Reading a perfect Lighthouse score as \"good PWA\"

Current Lighthouse doesn't look at the manifest, the worker, offline behavior or installability at all, unless you add custom audits. A PWA with a broken worker can score 100 in every category.

Only ever measuring cold loads

With the default storage reset, every run is a first visit. Caching regressions, such as a worker that stops serving the shell from cache, never show up. Add a warm run with disableStorageReset.

Asserting on removed audit IDs

CI configs written for Lighthouse 11 or earlier (installable-manifest, service-worker, maskable-icon, performance-budget) or for Lighthouse 12 (offscreen-images, font-size) fail with auditRan errors, or silently check nothing if the level is warn. Review assertions whenever you upgrade Lighthouse or LHCI.

Toggling offline before a Lighthouse navigation

Lighthouse resets network emulation at the start of each navigation, so the run happens online and passes. Make the origin unreachable instead.

Using optimistic aggregation for metrics

optimistic, LHCI's default, takes the best of the runs, so a regression that shows up in two of three runs passes. Use median or median-run for metric assertions, and pessimistic for pass/fail PWA checks.

Budgeting transfer size on warm runs

On a controlled load, everything served from Cache Storage has a transfer size of zero, and resource budgets pass no matter how much you ship. Budget cold runs.

Further reading

On this site

External references