Here’s a fact that surprises most developers the first time they see it in field data: a site’s second visit is often slower on Largest Contentful Paint than its first, even though the browser already has everything cached. The culprit, more often than not, is a service worker that was installed to speed things up and instead introduced its own bottleneck. Service workers are not automatically a performance win. They are a powerful, low-level tool that can either protect your Core Web Vitals on repeat visits or quietly destroy them, depending entirely on how they’re written.

This distinction matters because most Core Web Vitals advice focuses on the first visit — the cold cache, the empty browser store, the worst-case scenario. But a huge share of real traffic, especially for content sites, SaaS dashboards, and PWAs, comes from returning users. If your service worker strategy is wrong, you’re not just missing an opportunity; you’re actively degrading the experience for your most loyal visitors. This guide walks through, step by step, how to build a service worker that measurably improves repeat-visit LCP, INP, and CLS instead of undermining them.


Step 1: Understand That “Cached” and “Fast” Are Not the Same Thing

Before writing a single line of service worker code, it’s worth internalizing a distinction that trips up a lot of teams: having a resource in the Cache Storage API does not mean it will be delivered instantly.

A service worker sits between your page and the network as a programmable proxy. Every fetch event it intercepts must be handled by JavaScript running on a separate thread — the service worker thread — before a response is returned to the page. If that JavaScript is slow, poorly structured, or does unnecessary work (like checking multiple cache stores sequentially, or falling back to network before checking cache), the resource can arrive later than it would have without a service worker at all.

This is why some sites see LCP regress after adopting a service worker for “offline support.” The intercept logic itself becomes the bottleneck, even though the bytes are sitting locally on disk.

Step 2: Establish a Baseline Before You Touch Anything

Skipping this step is the single most common mistake. Before installing or modifying a service worker, capture field data (from the Chrome UX Report or your own Real User Monitoring) split by new versus returning visitors. Most analytics and RUM tools let you segment on a cache-control or a custom flag set on first load.

You want answers to three questions:

  • What is LCP on the first visit, with no service worker involved?
  • What is LCP on the second visit, before any service worker changes?
  • How much of the repeat-visit page weight is already served from the standard HTTP cache versus requiring a network round trip?

Without this baseline, you have no way to know afterward whether your service worker helped, hurt, or made no measurable difference. Teams that skip this step tend to ship changes based on intuition and then argue about results using lab data that doesn’t reflect real network conditions.

Step 3: Pick a Caching Strategy Per Resource Type, Not One Strategy for Everything

A common early mistake is applying a single caching strategy — usually cache-first — to every request the service worker intercepts. This works fine for immutable, versioned assets like hashed JS and CSS bundles. It works terribly for HTML documents or API responses that change frequently.

Match the strategy to the resource:

  • Cache-first: Best for static, versioned assets (fonts, hashed bundles, images). These rarely change, so serving from cache is both fast and correct.
  • Stale-while-revalidate: Best for resources that change occasionally but where slightly stale content is acceptable — think a blog’s list of recent posts, or a small JSON config file. The user gets the cached version instantly while a background fetch updates the cache for next time.
  • Network-first with cache fallback: Best for HTML navigations and frequently updated API data, where correctness matters more than raw speed, but you still want an offline fallback.
// A simple stale-while-revalidate handler for non-critical assets
self.addEventListener('fetch', event => {
  if (event.request.destination === 'image') {
    event.respondWith(
      caches.open('images-v1').then(async cache => {
        const cached = await cache.match(event.request);
        const networkFetch = fetch(event.request).then(response => {
          cache.put(event.request, response.clone());
          return response;
        });
        return cached || networkFetch;
      })
    );
  }
});

Mixing strategies like this is more code than a blanket cache-first approach, but it’s the difference between a service worker that improves Core Web Vitals and one that just moves the problem around.

Step 4: Precache the App Shell to Directly Protect LCP

If your LCP element is a hero image, a headline, or a piece of layout that depends on CSS, you can precache these assets during the service worker’s install event so they’re guaranteed to be available offline-first on the very next visit, with zero network dependency.

const APP_SHELL = [
  '/styles/critical.css',
  '/images/hero-banner.webp',
  '/fonts/main-variable.woff2'
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open('app-shell-v3').then(cache => cache.addAll(APP_SHELL))
  );
});

This is where the biggest repeat-visit LCP gains typically come from. Instead of the browser negotiating a conditional request with the server (even a fast 304 Not Modified still costs a round trip), the service worker intercepts the request and returns the cached response immediately, with no network involved at all. On a site with a slow or congested network path, this alone can shave several hundred milliseconds off LCP for returning visitors.

Step 5: Be Careful With Navigation Requests — This Is Where INP Damage Happens

Handling the HTML document request (the “navigation” request) inside a service worker is where a lot of well-intentioned implementations go wrong. Some developers wrap the entire navigation handler in complex logic — checking multiple caches, attempting background sync, logging analytics — all before returning a response.

Every millisecond spent in that fetch event handler delays the point at which the browser can start parsing HTML. That delay doesn’t show up as a traditional “blocking script” in the waterfall, which makes it easy to miss during a quick performance review. It shows up as a quietly worse Time to First Byte equivalent and, if the logic runs long enough, can contribute to poor Interaction to Next Paint scores when users try to interact with a page that’s still being assembled.

Keep navigation handlers minimal:

self.addEventListener('fetch', event => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => caches.match('/offline.html'))
    );
  }
});

Anything more elaborate belongs outside the critical path — deferred to a background task, not inline with the response the user is waiting on.

Step 6: Watch for CLS Regressions From Cached-but-Stale Layouts

Service workers can introduce a subtler Core Web Vitals problem: layout shift caused by serving an outdated cached version of a page alongside newly fetched dynamic content. If your cache-first HTML strategy serves an old layout structure while ads, embeds, or dynamically injected banners load with a different size than the cached page expects, you get a CLS spike that has nothing to do with images or fonts.

The fix is usually to keep any layout-affecting HTML out of the aggressively cached tier, or to version your cache keys alongside your deployment so that a new build automatically invalidates the old shell rather than mixing old structure with new dynamic content.

Step 7: Measure Repeat-Visit Impact Separately From First-Visit Impact

Once the service worker is live, go back to the segmented data from Step 2 and compare like for like. Don’t just look at your site’s overall Core Web Vitals average — a service worker’s effects are concentrated almost entirely in the returning-visitor segment, and averaging it with first-visit data can hide both real gains and real regressions.

Chrome’s DevTools Application panel and the Network panel (with “Offline” and “Update on reload” toggled correctly) let you simulate repeat visits locally, but field data from CrUX or your own RUM setup is the only source that tells you what’s actually happening for real users on real networks.


A Quick Health Check for Your Service Worker

Rather than a single scorecard, use this as a running checklist whenever you touch your service worker code:

  • Does every cached-first resource have a versioned cache name tied to your deployment process?
  • Is the navigation handler free of anything beyond a fetch, a fallback, and minimal logging?
  • Have you segmented your Core Web Vitals data by new versus returning visitor?
  • Does your LCP element specifically live in the precached app shell, or is it still dependent on a network round trip?
  • Are stale-while-revalidate resources actually being revalidated, or is the cache silently growing stale forever?

If you can check all five, your service worker is very likely helping rather than hurting. If you can’t, that’s usually where the next round of Core Web Vitals investigation should start.