Skip to Content
LoadingService Worker Analysis

Service worker analysis

Chrome 54+ Edge 17+ Firefox 58+ Safari 16.4+ Details

Analyzes the Service Worker lifecycle, cache behavior, and its performance impact on resource loading. Service Workers can dramatically improve performance through caching strategies, but misconfigured workers can introduce startup delays and degrade TTFB.

What this snippet measures

  • Service Worker registration state and active/waiting/installing workers
  • Navigation Preload status (reduces SW startup cost on navigation)
  • SW startup overhead on page load (impact on TTFB)
  • Resources intercepted by the SW vs. bypassed
  • Cache hit rate for SW-intercepted resources
  • Cache Storage inventory (caches and entry counts)

SW impact on navigation timing

Navigation request passes through the service worker, which either answers from Cache Storage with no network transfer or falls back to the network after a cache missSteps: Browser to Service Worker: Navigation request; Note: workerStart → fetchStart (SW startup overhead); alt Cache hit; Service Worker to Cache Storage: respondWith(caches.match()); Cache Storage to Service Worker: Cached response; Service Worker to Browser: Response (transferSize = 0); Note: Fast No network; else Cache miss; Service Worker to Network: fetch(request); Network to Service Worker: Network response; Service Worker to Browser: Response (transferSize > 0); Note: Network fallback.Navigation requestworkerStart → fetchStart(SW startup overhead)alt[Cache hit][Cache miss]respondWith(caches.match())Cached responseResponse (transferSize = 0)Fast No networkfetch(request)Network responseResponse (transferSize > 0)Network fallbackBrowserService WorkerCache StorageNetwork
Comparison of a navigation without preload, where the network request waits for service worker startup, and with navigation preload, where the request runs in parallel with startupSteps: Note: Without Navigation Preload; Browser to Service Worker: Start SW; Note: Startup delay...; Service Worker to Network: fetch(event.request); Network to Browser: Response; Note: With Navigation Preload; Browser to Service Worker: Start SW; Browser to Network: Preload request (parallel!); Note: Startup runs in parallel; Network to Service Worker: Preloaded response; Service Worker to Browser: Response (no extra delay).Without Navigation PreloadStart SWStartup delay...fetch(event.request)ResponseWith Navigation PreloadStart SWPreload request (parallel!)Startup runs in parallelPreloaded responseResponse (no extra delay)BrowserService WorkerNetwork

Snippet

Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.

// Service Worker Analysis
// https://webperf-snippets.nucliweb.net

(async () => {
  if (!('serviceWorker' in navigator)) {
    console.log(
      '%c⚠️ Service Workers not supported in this browser',
      'color: #f59e0b; font-weight: bold;'
    );
    return {
      script: "Service-Worker-Analysis",
      status: "unsupported",
      error: "Service Workers not supported in this browser",
      count: 0,
      details: {},
      items: [],
      issues: [{ severity: "error", message: "Service Workers not supported in this browser" }],
    };
  }

  const registrations = await navigator.serviceWorker.getRegistrations();
  const controller = navigator.serviceWorker.controller;
  const navEntry = performance.getEntriesByType('navigation')[0];
  const resources = performance.getEntriesByType('resource');

  // Resources intercepted by SW (workerStart > 0)
  const swResources = resources.filter((r) => r.workerStart > 0);
  // transferSize 0 with a body means served from cache. With no body size either, the
  // resource is cross-origin without Timing-Allow-Origin and its source is unknown.
  const fromCache = swResources.filter((r) => r.transferSize === 0 && r.encodedBodySize > 0);
  const fromNetwork = swResources.filter((r) => r.transferSize > 0);
  const unknownSource = swResources.filter((r) => r.transferSize === 0 && r.encodedBodySize === 0);
  const knownSource = fromCache.length + fromNetwork.length;
  const notIntercepted = resources.filter((r) => r.workerStart === 0);

  // SW Startup overhead
  let swOverheadMs = null;
  if (navEntry && navEntry.workerStart > 0) {
    const workerStart = navEntry.workerStart - navEntry.startTime;
    const fetchStart = navEntry.fetchStart - navEntry.startTime;
    swOverheadMs = Math.round(Math.max(fetchStart - workerStart, 0) * 10) / 10;
  }

  // Cache hit rate
  // Computed over the resources whose source is known
  const cacheHitRate = knownSource > 0
    ? parseFloat(((fromCache.length / knownSource) * 100).toFixed(1))
    : null;
  const savedBytes = fromCache.reduce((sum, r) => sum + (r.encodedBodySize || 0), 0);

  // Navigation preload states (collected once, reused in console output and return)
  const preloadStates = new Map();
  for (const reg of registrations) {
    if (reg.navigationPreload) {
      try {
        const state = await reg.navigationPreload.getState();
        preloadStates.set(reg, state);
      } catch {
        // Access may be restricted
      }
    }
  }

  // Cache Storage inventory
  const cacheStorage = [];
  if ('caches' in window) {
    try {
      const cacheNames = await caches.keys();
      for (const name of cacheNames) {
        const cache = await caches.open(name);
        const keys = await cache.keys();
        cacheStorage.push({ name, entries: keys.length });
      }
    } catch {
      // Cross-origin restrictions may prevent cache access
    }
  }

  const cacheStorageTop = [...cacheStorage].sort((a, b) => b.entries - a.entries).slice(0, 20);

  // Build registration items (homogeneous shape)
  const items = registrations.map((reg) => {
    const preload = preloadStates.get(reg) || null;
    let state = "none";
    let scriptURL = null;
    if (reg.active) {
      state = "active";
      scriptURL = reg.active.scriptURL;
    } else if (reg.waiting) {
      state = "waiting";
      scriptURL = reg.waiting.scriptURL;
    } else if (reg.installing) {
      state = "installing";
      scriptURL = reg.installing.scriptURL;
    }
    return {
      scope: reg.scope,
      scriptURL,
      state,
      hasWaiting: !!reg.waiting,
      hasInstalling: !!reg.installing,
      navigationPreloadEnabled: preload ? preload.enabled : null,
      navigationPreloadHeaderValue: preload?.headerValue || null,
    };
  });

  // Build issues
  const issues = [];

  if (registrations.length === 0) {
    issues.push({ severity: "info", message: "No Service Workers registered" });
  }

  if (registrations.length > 0 && !controller) {
    issues.push({
      severity: "warning",
      message: "Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload.",
    });
  }

  for (const item of items) {
    if (item.hasWaiting) {
      issues.push({
        severity: "warning",
        message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.`,
      });
    }
    if (item.navigationPreloadEnabled === false) {
      issues.push({
        severity: "info",
        message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().`,
      });
    }
  }

  if (swOverheadMs !== null && swOverheadMs > 100) {
    issues.push({
      severity: "error",
      message: `High SW startup time (${swOverheadMs}ms). Enable Navigation Preload to reduce overhead.`,
    });
  } else if (swOverheadMs !== null && swOverheadMs > 50) {
    issues.push({
      severity: "warning",
      message: `Moderate SW startup time (${swOverheadMs}ms)`,
    });
  }

  if (cacheHitRate !== null && cacheHitRate < 50) {
    issues.push({
      severity: "error",
      message: `Low SW cache hit rate (${cacheHitRate}%). Review the caching strategy.`,
    });
  } else if (cacheHitRate !== null && cacheHitRate < 80) {
    issues.push({
      severity: "warning",
      message: `Moderate SW cache hit rate (${cacheHitRate}%). Review the caching strategy for more resources.`,
    });
  }

  if (unknownSource.length > 0) {
    issues.push({
      severity: "info",
      message: `${unknownSource.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.`,
    });
  }

  const rating = cacheHitRate === null ? null
    : cacheHitRate >= 80 ? "good"
    : cacheHitRate >= 50 ? "needs-improvement"
    : "poor";

  // --- Console output ---

  console.group(
    '%c⚙️ Service Worker Analysis',
    'font-weight: bold; font-size: 14px;'
  );

  // Registrations
  console.log('');
  console.log('%c📋 Registrations:', 'font-weight: bold;');

  if (registrations.length === 0) {
    console.log('%c   ❌ No Service Workers registered', 'color: #ef4444;');
    console.groupEnd();
    return {
      script: "Service-Worker-Analysis",
      status: "ok",
      count: 0,
      details: {
        controlled: false,
        controllerState: null,
        swOverheadMs: null,
        totalResources: resources.length,
        swIntercepted: 0,
        notIntercepted: resources.length,
        fromCache: 0,
        fromNetwork: 0,
        fromUnknown: 0,
        cacheHitRate: null,
        savedBytes: 0,
        cacheStorageCount: cacheStorage.length,
        cacheStorage: cacheStorageTop,
      },
      items: [],
      issues,
    };
  }

  for (const reg of registrations) {
    console.log('');
    console.log(`%c   Scope: ${reg.scope}`, 'font-family: monospace;');

    if (reg.active) {
      console.log(
        `%c   ✅ Active: ${reg.active.state}`,
        'color: #22c55e;'
      );
      console.log(`      Script: ${reg.active.scriptURL}`);
    }
    if (reg.waiting) {
      console.log(
        '%c   ⏳ Waiting SW detected — update pending',
        'color: #f59e0b;'
      );
      console.log(`      Script: ${reg.waiting.scriptURL}`);
      console.log(
        '      💡 Call skipWaiting() to activate the new version'
      );
    }
    if (reg.installing) {
      console.log('%c   🔄 Installing...', 'color: #3b82f6;');
    }

    const preload = preloadStates.get(reg);
    if (preload !== undefined) {
      console.log('');
      console.log('%c   🚀 Navigation Preload:', 'font-weight: bold;');
      if (preload.enabled) {
        console.log('%c      ✅ Enabled', 'color: #22c55e;');
        console.log(`      Header value: "${preload.headerValue}"`);
      } else {
        console.log('%c      ❌ Disabled', 'color: #ef4444;');
        console.log(
          '      💡 Enable with: registration.navigationPreload.enable()'
        );
      }
    }
  }

  // Controller
  console.log('');
  console.log('%c🎮 Controller:', 'font-weight: bold;');
  if (controller) {
    console.log('%c   ✅ Page is controlled by SW', 'color: #22c55e;');
    console.log(`   State: ${controller.state}`);
  } else {
    console.log('%c   ⚠️ Page is NOT controlled by SW', 'color: #f59e0b;');
    console.log('   Hard reload detected or first visit. Do a normal reload.');
  }

  // SW Startup overhead
  if (navEntry && navEntry.workerStart > 0) {
    const workerStart = navEntry.workerStart - navEntry.startTime;
    const fetchStart = navEntry.fetchStart - navEntry.startTime;

    console.log('');
    console.log('%c⏱️ SW Startup Overhead:', 'font-weight: bold;');
    console.log(`   Worker start:  ${workerStart.toFixed(1)}ms`);
    console.log(`   Fetch start:   ${fetchStart.toFixed(1)}ms`);
    console.log(`   SW overhead:   ${swOverheadMs.toFixed(1)}ms`);

    if (swOverheadMs > 100) {
      console.log(
        '%c   🔴 High SW startup time — enable Navigation Preload',
        'color: #ef4444;'
      );
    } else if (swOverheadMs > 50) {
      console.log(
        '%c   🟡 Moderate SW startup time',
        'color: #f59e0b;'
      );
    } else {
      console.log(
        '%c   🟢 SW startup overhead is low',
        'color: #22c55e;'
      );
    }
  }

  // Cache hit/miss ratio
  console.log('');
  console.log('%c📊 Resource Cache Analysis:', 'font-weight: bold;');
  console.log(`   Total resources:     ${resources.length}`);
  console.log(`   SW intercepted:      ${swResources.length}`);
  console.log(`   Not intercepted:     ${notIntercepted.length}`);

  if (swResources.length > 0) {
    console.log('');
    console.log('   SW-intercepted breakdown:');
    console.log(`   ├─ Served from cache:   ${fromCache.length} (${cacheHitRate ?? 0}% hit rate)`);
    console.log(`   ${unknownSource.length > 0 ? '├' : '└'}─ Fetched from network: ${fromNetwork.length}`);
    if (unknownSource.length > 0) {
      console.log(`   └─ Unknown source:      ${unknownSource.length} (cross-origin, missing Timing-Allow-Origin)`);
    }
    console.log(`   Network bytes saved: ~${(savedBytes / 1024).toFixed(1)} KB`);

    if (cacheHitRate >= 80) {
      console.log('%c   🟢 Excellent cache hit rate', 'color: #22c55e;');
    } else if (cacheHitRate >= 50) {
      console.log('%c   🟡 Good cache hit rate', 'color: #f59e0b;');
    } else {
      console.log(
        '%c   🔴 Low cache hit rate — review caching strategy',
        'color: #ef4444;'
      );
    }

    console.log('');
    console.log('%c📋 SW-intercepted Resources (top 20):', 'font-weight: bold;');
    console.table(
      swResources.slice(0, 20).map((r) => ({
        'Cache': r.transferSize > 0 ? '🌐 Network' : r.encodedBodySize > 0 ? '✅ Cache' : '❓ Unknown',
        'Transfer (KB)': r.transferSize > 0 ? (r.transferSize / 1024).toFixed(1) : '0',
        'Duration (ms)': r.duration.toFixed(0),
        'Type': r.initiatorType,
        'URL': r.name.length > 60 ? '...' + r.name.slice(-57) : r.name,
      }))
    );
  }

  // Cache Storage inventory
  if (cacheStorage.length > 0) {
    const totalCacheEntries = cacheStorage.reduce((sum, c) => sum + c.entries, 0);
    console.log('');
    console.log('%c💾 Cache Storage:', 'font-weight: bold;');
    for (const { name, entries } of cacheStorage) {
      console.log(`   ├─ "${name}": ${entries} entries`);
    }
    console.log(`   Total entries: ${totalCacheEntries}`);
  }

  console.groupEnd();

  return {
    script: "Service-Worker-Analysis",
    status: "ok",
    count: registrations.length,
    ...(rating !== null && { rating }),
    details: {
      controlled: !!controller,
      controllerState: controller?.state || null,
      swOverheadMs,
      totalResources: resources.length,
      swIntercepted: swResources.length,
      notIntercepted: notIntercepted.length,
      fromCache: fromCache.length,
      fromNetwork: fromNetwork.length,
      fromUnknown: unknownSource.length,
      cacheHitRate,
      savedBytes,
      cacheStorageCount: cacheStorage.length,
      cacheStorage: cacheStorageTop,
    },
    items,
    issues,
  };
})();

Interpreting the results

Service worker not controlled (Page is NOT controlled by SW)

The page was loaded with a hard reload (Ctrl+Shift+R / Cmd+Shift+R) or it’s the very first visit. In this state the SW is registered but doesn’t intercept requests.

Fix: Do a normal reload (F5 / Cmd+R) and run the snippet again.

Waiting SW detected (Waiting SW detected)

A new SW version was installed but it’s waiting for all open tabs to close before activating. This is the default browser behavior to avoid version mismatches.

SituationAction
DevelopmentCall skipWaiting() in the SW to force activation
ProductionClose all tabs and reopen, or add an “Update available” prompt

SW startup overhead

The overhead is the time between workerStart and fetchStart in the Navigation Timing entry, the time the browser spends starting the SW before the actual fetch can begin.

RatingOverheadMeaning
Good< 50msSW is running or starts fast
Needs attention50–100msConsider Navigation Preload
Poor> 100msSW cold start adds visible latency to TTFB

A high overhead on first load is normal (SW cold start). On subsequent navigations it should be near zero if the SW is already running. Enable Navigation Preload to eliminate this overhead regardless.

Without Navigation Preload, the browser must wait for the SW to start before sending the navigation request to the network. Enable it in the SW activate event:

self.addEventListener('activate', (event) => { event.waitUntil(self.registration.navigationPreload.enable()); });

Then consume the preloaded response in the fetch handler:

self.addEventListener('fetch', (event) => { event.respondWith(async function () { const cached = await caches.match(event.request); if (cached) return cached; const preloadResponse = await event.preloadResponse; if (preloadResponse) return preloadResponse; return fetch(event.request); }()); });

Cache hit rate

RatingHit rateMeaning
Excellent≥ 80%Most resources served from cache
Good50–79%Mixed cache/network, review strategy
Low< 50%Cache strategy too conservative or cache is cold

A low hit rate on first visit is expected since the cache hasn’t been populated yet. Run the snippet on a second visit to get a meaningful measurement.

transferSize = 0 vs. actual cache hit

transferSize = 0 for SW-intercepted resources means no bytes came from the network, the response came from Cache Storage or the HTTP cache. It does not distinguish between the two. To confirm a response came specifically from Cache Storage, check your SW fetch handler logic or use the Application → Cache Storage panel in DevTools.

Cross-origin resources without a Timing-Allow-Origin header report transferSize = 0 and encodedBodySize = 0. The snippet cannot tell where these came from, so it lists them as Unknown source and leaves them out of the hit rate.

Resources NOT intercepted by SW

Resources with workerStart = 0 were not intercepted. Common reasons:

  • Cross-origin requests excluded from the SW fetch scope
  • Requests made before the SW finished activating
  • Resources with no-cors mode that the SW skips
  • The SW fetch handler doesn’t match their URL pattern

Cache storage is empty

The SW is registered but hasn’t cached anything yet. This happens on first visit before the install event pre-caches assets, or if the caching strategy is purely runtime (cache-on-first-use).

Browser support

The snippet needs these features to run.

FeatureChromeEdgeFirefoxSafari
ServiceWorkerContainer.getRegistrations (opens in a new tab) 45 17 44 11.1
PerformanceResourceTiming.workerStart (opens in a new tab) 46 16 58 11
PerformanceResourceTiming.transferSize (opens in a new tab) 54 17 45 16.4
All of the above 54 17 58 16.4

The snippet reports on these features. It runs without them, and its advice on each one applies where it is supported.

FeatureChromeEdgeFirefoxSafari
NavigationPreloadManager (opens in a new tab) 59 18 99 15.4

Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.

Further reading

Last updated on
MIT 2026 © Joan León | @nucliweb