Your first-time visitors wait for the network. Your returning visitors? They don't have to. Service workers hand you programmatic control over exactly what gets cached, how it's served, and when it's refreshed — turning repeat visits into near-instant page loads that can dramatically improve your Core Web Vitals scores.
I've been implementing service workers on production sites for years now, and honestly, the performance gains on repeat visits still surprise me. We're talking about pages that load so fast they feel like native apps.
This guide walks you through every major caching strategy, shows you how to implement them with Workbox 7, and explains how service worker caching and HTTP caching work together to create a multi-layered performance shield.
Why Service Worker Caching Matters for Web Performance
HTTP caching (via Cache-Control headers) has been the default browser caching mechanism for decades. It works, but it's a blunt instrument. The server tells the browser what to cache and for how long, and the browser either uses the cached copy or fetches a new one. You don't get any say in how the cache is consulted at request time.
Service workers change this entirely.
A service worker is a JavaScript file that sits between your web page and the network, intercepting every outgoing request. For each request, your code decides whether to serve from cache, fetch from the network, or combine both approaches. This fine-grained control unlocks strategies that HTTP caching simply can't provide:
- Offline support — serve full pages without any network connection
- Instant repeat loads — serve cached HTML, CSS, and JS before the network even responds
- Background updates — silently refresh cached assets while showing the user the cached version immediately
- Per-URL routing — apply different caching strategies to different resource types
Real-world data from Google's web.dev case studies shows that sites with service workers load significantly faster on repeat visits — even compared to returning visitors who already have HTTP-cached resources. The service worker bypasses the HTTP cache entirely when it has a matching response, shaving off the overhead of cache validation requests. That alone is a big deal.
The Service Worker Lifecycle
Before diving into strategies, you need to understand the lifecycle. It directly affects when caching kicks in, and getting this wrong is one of the most common sources of confusion.
- Registration — your page registers the service worker file. This only happens once per scope.
- Installation — the browser downloads and installs the service worker. This is where precaching happens: you preload critical assets into cache storage during the
installevent. - Activation — the service worker takes control. Old caches from previous versions can be cleaned up in the
activateevent. - Fetch interception — from this point on, every network request from controlled pages passes through the service worker's
fetchevent handler, where your caching strategy runs.
Critical caveat: a service worker does not control the page that registered it on the first visit. The user must navigate away and return (or you can call clients.claim()) before interception begins. This means service worker caching is primarily a repeat-visit optimization — something that trips up a lot of developers who expect instant results.
The Five Core Caching Strategies
Every service worker caching implementation is built from these fundamental patterns. Choosing the right one for each resource type is the key to balancing speed, freshness, and reliability.
So, let's break them down.
1. Cache First (Cache Falling Back to Network)
Check the cache first. If a cached response exists, return it immediately. If not, fetch from the network, cache the response, and return it. Simple and effective.
// Vanilla service worker — Cache First
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open('static-v1').then((cache) => cache.put(event.request, clone));
return response;
});
})
);
});
Best for: static assets that rarely change — versioned JS bundles, CSS files, font files, and images. These resources have hashed filenames, so a cache hit is always safe to serve.
Tradeoff: users may see stale content if you cache resources that change without URL changes. Always use filename hashing for cache-first assets. Seriously, don't skip this step.
2. Network First (Network Falling Back to Cache)
Try the network first. If the request succeeds, cache the fresh response and return it. If the network fails (timeout, offline), serve the cached fallback.
// Vanilla service worker — Network First
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open('dynamic-v1').then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});
Best for: HTML pages and API responses where freshness is critical but you still want offline fallback capability.
Tradeoff: no speed benefit on repeat visits when the network is available — the user waits for the full network response. This is the strategy I see people default to when they're not sure what to pick, and it's often not the best choice.
3. Stale-While-Revalidate
This one's my personal favorite. Return the cached response immediately (instant load), then fetch a fresh copy from the network in the background to update the cache for next time.
// Vanilla service worker — Stale-While-Revalidate
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('swr-v1').then((cache) => {
return cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
})
);
});
Best for: resources that benefit from fast loads but also need periodic updates — avatars, non-critical API data, social feeds, and analytics libraries.
Tradeoff: the user may see content that's one version behind. The cache is always updated after the request, so the next visit will show the latest version. For most use cases, this is a perfectly acceptable trade.
4. Cache Only
Serve exclusively from the cache. Never go to the network. This only works for precached assets that are guaranteed to exist in cache storage.
Best for: app shell assets in progressive web apps — the HTML skeleton, core CSS, and offline fallback page that you precache during installation.
5. Network Only
Always go to the network. The service worker doesn't interact with the cache at all. Use this as a pass-through for requests that should never be cached.
Best for: analytics pings, non-GET requests (POST, PUT, DELETE), real-time data streams, and anything involving authentication tokens.
Precaching vs. Runtime Caching
These two concepts determine when assets enter the cache, and understanding the difference is key to getting your strategy right.
Precaching happens during the service worker's install event. You define a manifest of critical URLs, and the service worker downloads and caches all of them before it activates. The result: those assets are available from cache on the very first controlled page load, with zero network delay.
Runtime caching happens on-demand, as the user browses. When a request matches a route you've configured, the caching strategy kicks in and the response gets cached for future use. Runtime caching is ideal for assets you can't predict at build time — user-uploaded images, paginated API results, or third-party resources.
The optimal approach combines both: precache your critical app shell and core assets, then use runtime caching with appropriate strategies for everything else. It sounds simple enough, but I've seen plenty of projects that try to precache everything and end up with painfully slow initial installs.
Implementing Service Worker Caching with Workbox 7
Writing service workers from scratch is error-prone (trust me, I learned the hard way). Workbox is Google's open-source library that abstracts the low-level Cache Storage and Service Worker APIs into a clean, declarative interface. It's used by 54% of mobile sites that implement service workers, and integrates natively with Vite, webpack, and Next.js.
Setting Up Workbox with a Build Tool
The fastest path is using the generateSW mode, which creates an entire service worker for you based on your configuration:
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
strategies: 'generateSW',
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,woff2,svg}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24, // 24 hours
},
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
},
},
},
],
},
}),
],
};
This configuration precaches all JS, CSS, HTML, fonts, and SVGs at build time, then applies runtime caching with stale-while-revalidate for API calls and cache-first for images. Pretty clean setup for the amount of power it gives you.
Custom Service Worker with injectManifest
For full control, use injectManifest mode. You write the service worker yourself, and Workbox injects the precache manifest:
// src/sw.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// Precache build assets — Workbox injects the manifest here
precacheAndRoute(self.__WB_MANIFEST);
// HTML pages — Network First for freshness, cache fallback for offline
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({
cacheName: 'pages-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 7 * 24 * 60 * 60 }),
],
})
);
// Images — Cache First with expiration
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
})
);
// Google Fonts stylesheets — Stale While Revalidate
registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com',
new StaleWhileRevalidate({ cacheName: 'google-fonts-stylesheets' })
);
// Google Fonts webfont files — Cache First (long-lived)
registerRoute(
({ url }) => url.origin === 'https://fonts.gstatic.com',
new CacheFirst({
cacheName: 'google-fonts-webfonts',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 }),
],
})
);
This setup gives you a layered caching architecture: precached app shell for instant loads, network-first HTML for fresh content, cache-first images for speed, and stale-while-revalidate fonts for the best balance. It's the configuration I keep coming back to on most projects.
Cache Expiration and Storage Management
Without expiration rules, your caches grow indefinitely. That's a problem. The ExpirationPlugin enforces two limits:
maxEntries— the maximum number of items in a cache. When exceeded, the oldest entry is evicted.maxAgeSeconds— the maximum age of a cached item. Expired entries are removed on the next cache access.
Set these conservatively. A cache with 500 large images will consume significant device storage, and you don't want to be the reason someone's phone runs out of space. For most sites, 50–100 entries per cache with 7–30 day expiration is a solid starting point.
Service Worker Caching vs. HTTP Caching: How They Work Together
If you already have HTTP caching configured (as covered in our HTTP caching guide), you might wonder whether service worker caching is redundant. It's not — they operate at different layers and actually complement each other quite well:
| Feature | HTTP Cache | Service Worker Cache |
|---|---|---|
| Control | Server-driven (response headers) | Developer-driven (JavaScript) |
| Offline support | None | Full |
| Strategy flexibility | Cache or not (binary) | Multiple strategies per route |
| Automatic expiration | Yes (via max-age, expires) | No (you manage it explicitly) |
| Browser purging | Can be evicted under memory pressure | More persistent, survives browser restarts |
The request priority order is: Service Worker Cache → HTTP Cache → Network. When a service worker intercepts a request and has a cached response, the HTTP cache is never consulted. When the service worker passes a request to the network (e.g., in stale-while-revalidate's background fetch), the HTTP cache may still serve the request if it has a valid entry.
Pro tip: set shorter Cache-Control max-age values on resources you cache with the service worker. This gives the service worker primary control while the HTTP cache acts as a secondary fallback layer. It's a small configuration detail that avoids a lot of headaches down the road.
Measuring the Impact on Core Web Vitals
Service worker caching primarily improves these metrics for returning visitors:
- TTFB — when the HTML itself is served from the service worker cache, TTFB drops to near zero because no network request is made at all.
- LCP — if the LCP element (hero image, heading) is in cached HTML or a precached image, LCP improves dramatically. Sites using service worker cache-first strategies report LCP reductions of 40–60% on repeat visits.
- FCP — cached CSS and HTML mean the browser can begin rendering immediately without waiting for network responses.
Here's how you can measure the real impact in production using the Performance Observer API:
// Measure whether the service worker served the navigation
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'navigation') {
const swControlled = navigator.serviceWorker?.controller !== null;
const transferSize = entry.transferSize;
console.log('SW controlled:', swControlled);
console.log('Transfer size:', transferSize);
// transferSize === 0 indicates the response came from cache
if (transferSize === 0 && swControlled) {
console.log('Page served from service worker cache');
}
}
}
});
observer.observe({ type: 'navigation', buffered: true });
Common Pitfalls and How to Avoid Them
I've run into (or helped debug) all of these at some point. Save yourself the trouble.
Caching Opaque Responses
Cross-origin requests without CORS headers produce opaque responses. These have a status of 0, and you can't inspect their content. Caching a failed opaque response with a cache-first strategy means serving a broken resource indefinitely — and that's a surprisingly common mistake.
Use CacheableResponsePlugin in Workbox to whitelist only status codes 0 and 200, and prefer stale-while-revalidate for cross-origin resources so failures get replaced on the next fetch.
Forgetting to Version Caches
If you update your service worker but reuse the same cache name, old and new assets can mix. Always version your cache names (e.g., static-v2) or use Workbox's precaching, which handles cache versioning automatically through content hashing.
Precaching Too Many Assets
Precaching your entire site on first install forces the user to download everything upfront, delaying service worker activation and wasting bandwidth. I've seen precache manifests balloon to 5 MB+ on some projects — that's a terrible first impression.
Precache only the critical app shell — your main HTML template, core CSS, and essential JS. Use runtime caching for everything else.
Not Handling Service Worker Updates
When you deploy a new service worker, the old one stays active until all tabs are closed. Users can be stuck on stale code for days. Use Workbox's skipWaiting() and clientsClaim() for immediate activation, or implement an update prompt that asks users to reload.
// In your service worker
import { clientsClaim } from 'workbox-core';
self.skipWaiting();
clientsClaim();
Frequently Asked Questions
Does service worker caching work on the first visit?
No. The service worker must be installed and activated before it can intercept requests. On the very first visit, the service worker is registered and installed in the background, but it doesn't control the page. The user must navigate away and return (or you can call clients.claim()) for caching to take effect. This is why service worker caching is primarily a repeat-visit optimization.
Is the service worker cache faster than the browser HTTP cache?
It depends on the workload. For small numbers of concurrent requests, the HTTP disk cache can be marginally faster. For larger numbers of concurrent requests, the service worker cache tends to outperform the HTTP cache. But in practice, the real advantage isn't raw speed — it's the programmatic control, offline capability, and the ability to apply different strategies to different resources.
How much storage can a service worker cache use?
Storage quotas vary by browser. Chrome and Edge allocate up to 80% of total disk space per origin (shared with IndexedDB and other storage APIs). Firefox allows up to 2 GB per origin. Safari imposes a stricter 1 GB limit and may evict storage after 7 days without user interaction. Always implement cache expiration policies to stay well within these limits.
Can service worker caching improve my Lighthouse score?
Yes, but primarily for audits that simulate repeat visits or measure offline capability. Lighthouse's default run simulates a first visit without service worker control, so the speed metrics won't change. However, Lighthouse does check for service worker registration and offline fallback pages in its PWA audit section. Where you'll really see the difference is in real-user monitoring (RUM) — service worker caching will noticeably improve LCP, FCP, and TTFB for returning visitors.
Should I use service worker caching if I already have a CDN?
Absolutely. A CDN caches resources on edge servers close to the user, reducing network latency. A service worker caches resources directly on the user's device, eliminating network latency entirely. They operate at different layers and stack their benefits: the CDN speeds up the first visit, and the service worker makes repeat visits near-instant. For the best performance, use both together — they're not competing, they're complementary.