Skip to content
DERKONLINE

Pick the Service Worker Caching Strategy That Fits

Cache-first, network-first, and stale-while-revalidate compared, plus the versioning and purge logic that stops users seeing stale screens.

Derrick S. K. Siawor8 min read

A service worker is the most powerful caching tool the browser gives you and the easiest one to turn into a support nightmare. Get the strategy right and your app loads instantly, works offline, and feels native. Get the versioning wrong and you ship an update that nobody sees, because every returning user is being served last week's app from a cache that never got cleared, and the bug you fixed is still live for everyone who visited before. The power and the danger come from the same place: a service worker sits between your app and the network and decides, on its own, what to serve.

The mistake most teams make is picking one caching strategy and applying it to everything. There is no single right strategy, because different kinds of content have different needs. A version-hashed JavaScript bundle and a live API response should be cached in opposite ways, and a strategy that suits one ruins the other. The skill is matching the strategy to the content, and then building the versioning and purge logic that stops users from getting stuck on a stale version. Here is how to choose, and how to avoid the trap.

The three strategies, and what each is actually for

There are three caching strategies worth knowing, and each makes a different trade between speed and freshness.

Cache-first: speed, for things that do not change

Cache-first serves from the cache whenever the resource is there, and only goes to the network if it is missing. The cached copy is returned immediately, with no network round trip, which makes this the fastest strategy. The catch is that a cache-first resource is frozen at whatever you cached, so it is only safe for content that does not change, or whose change you control through its URL.

This is the right strategy for static assets with versioned filenames: a JavaScript bundle named with a content hash, an image, a font. Cache-first on the app shell is also what lets you design an app shell so users never stare at a spinner, because the chrome paints from cache the instant the page opens. Because the filename changes when the content changes, you never have to worry about serving a stale version, the new content has a new URL and the old cached entry simply stops being requested. Cache-first plus versioned filenames is the foundation of a fast app: the bytes are served instantly from cache, and freshness is handled by the URL changing rather than by the cache expiring.

Network-first: freshness, with an offline safety net

Network-first tries the network first and serves that response, falling back to the cache only when the network fails, times out, or errors. This prioritizes freshness, so it suits content that should always be current when possible: an API response, a feed, a dashboard, anything where serving a stale version would be wrong but serving something when offline is better than serving nothing. When a write happens offline and syncs later, this is where you need offline-first mobile sync that survives bad networks to reconcile the conflicts.

The trade is speed. Every request waits on the network before responding, which is slower than cache-first, so you pay a latency cost for the freshness. The payoff is the offline fallback: when the network is unavailable, the user still sees the last cached version instead of an error, which is the difference between a PWA that works on a flaky connection and one that breaks. That offline resilience is a real part of the calculation when a PWA beats a native app for your startup's budget.

Stale-while-revalidate: fast now, fresh next time

Stale-while-revalidate is the balance, and it is the right default for a lot of content. It serves the cached version immediately, so the response is instant, the same instinct behind making slow feel fast with optimistic UI and smart skeletons, and at the same time it fetches a fresh copy from the network in the background and updates the cache. The user gets speed on this request and freshness on the next one.

This suits content where being one version behind is acceptable but staying behind forever is not: a profile, a list that updates occasionally, an avatar, content that changes but not so urgently that the user must have the absolute latest on every single load. The user never waits, and the content stays current within one request of behind, which for most app content is exactly the right trade.

The decision, then, is per resource type. Version-hashed static assets go cache-first. Live data that must be current goes network-first. Everything in between, where instant-but-slightly-behind is fine, goes stale-while-revalidate. A service worker that applies the right strategy to each kind of content is fast and fresh at once. One that applies a single strategy everywhere is fast and stale, or fresh and slow.

Decision tree mapping content type to cache-first, network-first, or stale-while-revalidate strategy

The stale-content trap, and why it is the dangerous one

Here is the failure that turns a service worker from an asset into a liability. A service worker using a cache-first strategy serves cached content before checking the network. If you deploy a new version of the site and the cache is not invalidated, the service worker keeps serving the old version, indefinitely, to every returning user. You shipped the fix. Nobody got it. The app works, which is what makes it insidious, it just works as the old version, and you cannot tell from your own machine because your cache is fresh.

This is the single most common service-worker bug, and it is entirely a versioning and cleanup problem. The fix has two parts.

Version your caches

Name your caches with a version: app-cache-v3, not just app-cache. The version is the thing that lets you tell the new cache from the old one, and it is what makes cleanup possible. When you deploy a change, you bump the version, and the new service worker creates a fresh cache under the new name rather than reusing the stale one.

Purge the old caches on activate

A service worker's lifecycle gives you exactly the hook you need. When a new service worker takes over, it receives an activate event, and the purpose of that event is to clean up resources from previous versions. Use it to delete every cache that does not match the current version, so the old, stale entries are removed and the new ones take their place.

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((name) => name !== CURRENT_CACHE)
          .map((name) => caches.delete(name))
      )
    )
  );
});

That listener deletes any cache not matching the current version every time a new worker activates, so an update reliably clears the old assets instead of leaving them to be served forever. Combined with versioned cache names and prompting the new worker to take control rather than waiting indefinitely, this is what makes deploys actually reach your users. The tooling in this space, like Workbox, automates the precaching, versioning, and cleanup so you are not hand-writing the lifecycle logic, and for most apps reaching for that tooling is the right call rather than rolling it yourself.

Build it so the user never sees the old screen

The throughline is that a service worker has to be designed for updates, not just for caching. Caching is the easy part, any strategy will cache. The hard part is making sure that when you ship a change, the change reaches the people who already have the app cached, and that is purely a function of versioning and purge discipline. A service worker without that discipline is a time bomb: it makes your app fast right up until the deploy that everyone misses.

This fits the standard we hold for everything user-facing: the user should never be stuck on a stale or broken screen, and an update should reach them without their having to clear their cache or hard-refresh, which is a developer workaround leaking into the product. The same offline caching is what lets a PWA feel native on iOS despite Safari's limits. When we build web applications and websites with offline support, the caching strategy is chosen per resource and the update path is built so a deploy reliably reaches returning users, because a fast app that silently serves the old version is worse than a slightly slower one that is always current. The signed, versioned channel behind running your own release channel for self-hosted app updates solves the same reach-the-user problem for installed software.

The short version

Do not pick one caching strategy for everything. Use cache-first for version-hashed static assets, where freshness is handled by the URL changing. Use network-first for live data that must be current, with the cache as an offline fallback. Use stale-while-revalidate as the default for content where instant-but-one-behind is fine. Then version your caches and purge the old ones on the activate event, because the most common service-worker bug is a cache-first worker that serves a stale version forever after you deploy.

Match the strategy to the content and build the update path deliberately, and a service worker makes your app feel native. Skip the versioning and it makes your fixes invisible, which is the one outcome worse than no caching at all.