Claude Code Service Worker Guide for Offline-Ready Apps
Build safe service workers with Claude Code: caching, updates, offline UX, pitfalls, and runnable examples.
A service worker is one of the most useful browser features for PWA and offline work, but it is also easy to misuse. If you ask Claude Code to “add caching” without boundaries, the app may keep old HTML, cache private account data, or fail to show a new release after deployment.
This guide treats a service worker as a small proxy between the browser and your server. It explains what to decide before using Claude Code, then gives a copy-pasteable vanilla implementation with cache invalidation, update prompts, offline UX, realistic use cases, and concrete failure cases.
Keep the official references open while implementing: MDN Service Worker API, web.dev service worker guidance, web.dev caching guidance, and Chrome Workbox docs. For related ClaudeCodeLab context, read the PWA implementation guide, caching strategies guide, and IndexedDB guide.
What a Service Worker Does
A service worker is JavaScript that runs outside the web page. It cannot touch the DOM, so it cannot directly change buttons, forms, or React state. What it can do is intercept eligible network requests and decide whether to respond from the network, from Cache API, or from a planned offline fallback.
The normal page script ends when the tab closes. A service worker is event-driven: the browser wakes it for install, activate, fetch, push, and sometimes background sync events. For most production apps, the important starting points are fetch handling, Cache API, and the update lifecycle.
sequenceDiagram
participant User
participant Page
participant SW as Service Worker
participant Cache as Cache API
participant Net as Server
User->>Page: Opens the site
Page->>SW: Registers /sw.js
Page->>SW: Makes a fetch request
SW->>Cache: Checks cached response
alt Cached
Cache-->>SW: Stored response
else Not cached
SW->>Net: Fetches latest response
Net-->>SW: Fresh response
end
SW-->>Page: Response to render
That makes a service worker a traffic controller, not a magic performance switch. The quality comes from choosing what to store, when to delete it, and what users see when the network fails.
Practical Use Cases
Service workers are most valuable when the app has repeat visits, expensive static assets, or users with unreliable networks.
| Use case | Why it helps | Main caution |
|---|---|---|
| Documentation or content site | Revisited articles, CSS, images, and fonts load faster | Long-lived HTML can hide fresh edits |
| SaaS dashboard | Navigation shell works on weak connections | Never cache private billing or account responses |
| Field input app | Users can keep working with drafts while offline | Store POST work in IndexedDB, not Cache API |
| Commerce or media catalog | Thumbnails and static assets avoid repeated downloads | Prices, stock, and protected images need freshness rules |
Masa tested the pattern on a small learning site. Caching images and fonts gave a clear repeat-visit improvement. Caching article HTML with a careless Cache First strategy caused a visible bug: typo fixes did not reach returning readers. The lesson is simple: do not ask Claude Code to cache everything. Give it ownership rules.
Brief Claude Code Correctly
Service worker prompts should include prohibited behavior and validation steps. A vague request produces code that may work in a demo while failing in production.
Add a service worker to the existing Vite app.
Requirements:
- Put /sw.js under public and use scope /
- Cache only GET static assets
- Use Network First for HTML navigations
- Return /offline.html when navigation fails offline
- Do not cache APIs, POST requests, auth pages, or other origins
- Include a date or version in the cache name
- Delete old caches during activate
- Show a reload prompt when a new worker is waiting
Verification:
- Check registration in Chrome DevTools > Application > Service Workers
- Switch Network to Offline and confirm /offline.html appears
- Change CACHE_VERSION and confirm old caches are deleted
The explicit exclusions matter. API responses, authenticated HTML, and POST requests are where service worker bugs become data bugs.
Copy-Paste Minimal Implementation
This vanilla example works without a framework. Put these four files in an empty directory such as sw-demo, then start a local server. Service workers require HTTPS or localhost, so opening the HTML file directly is not enough.
python -m http.server 5173
Open http://localhost:5173 in the browser.
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Service Worker Demo</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 2rem;
line-height: 1.7;
}
button {
padding: 0.7rem 1rem;
}
</style>
</head>
<body>
<h1>Service Worker Demo</h1>
<p id="status">Waiting for registration.</p>
<button type="button" onclick="location.reload()">Reload</button>
<script src="/register-sw.js"></script>
</body>
</html>
// register-sw.js
const status = document.querySelector("#status");
let reloadRequested = false;
let updatePromptShown = false;
function setStatus(message) {
if (status) status.textContent = message;
}
function askToReload(worker) {
if (updatePromptShown) return;
updatePromptShown = true;
const ok = window.confirm(
"A new version is available. Reload now?",
);
if (ok) {
reloadRequested = true;
worker.postMessage({ type: "SKIP_WAITING" });
}
}
async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) {
setStatus("This browser does not support service workers.");
return;
}
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
setStatus(`Service worker registered: ${registration.scope}`);
if (registration.waiting && navigator.serviceWorker.controller) {
askToReload(registration.waiting);
}
registration.addEventListener("updatefound", () => {
const worker = registration.installing;
if (!worker) return;
worker.addEventListener("statechange", () => {
const hasOldController = Boolean(navigator.serviceWorker.controller);
if (worker.state === "installed" && hasOldController) {
askToReload(worker);
}
});
});
} catch (error) {
console.error(error);
setStatus("Service worker registration failed.");
}
}
navigator.serviceWorker?.addEventListener("controllerchange", () => {
if (!reloadRequested) return;
window.location.reload();
});
registerServiceWorker();
// sw.js
const CACHE_VERSION = "2026-06-02-v1";
const CACHE_PREFIX = "claude-sw-demo";
const CACHE_NAME = `${CACHE_PREFIX}-${CACHE_VERSION}`;
const APP_SHELL = [
"/",
"/index.html",
"/offline.html",
"/register-sw.js",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => name.startsWith(CACHE_PREFIX))
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name)),
),
),
);
self.clients.claim();
});
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(networkFirstNavigation(request));
return;
}
if (["style", "script", "font", "image"].includes(request.destination)) {
event.respondWith(staleWhileRevalidate(request));
}
});
async function networkFirstNavigation(request) {
const cache = await caches.open(CACHE_NAME);
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch {
return (
(await cache.match(request)) ||
(await cache.match("/offline.html")) ||
new Response("Offline", { status: 503 })
);
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetched = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
.catch(() => cached || new Response("Offline", { status: 503 }));
return cached || fetched;
}
<!-- offline.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>You are offline</title>
</head>
<body>
<main>
<h1>You are offline</h1>
<p>Reconnect, then reload this page.</p>
<button type="button" onclick="location.reload()">Try again</button>
</main>
</body>
</html>
This example uses Network First for page navigation and Stale While Revalidate for CSS, JavaScript, fonts, and images. Network First asks the server first and falls back only when the request fails. Stale While Revalidate returns a cached response immediately while refreshing it in the background. Do not use that strategy blindly for news, prices, inventory, or authenticated screens.
Updates and Invalidation
The update lifecycle is where many service worker implementations fail. When sw.js changes, the browser installs a new worker. If an older page is still open, the new worker often waits in the waiting state. The registration code above detects that state, asks the user, and sends SKIP_WAITING only when the user accepts.
The worker then calls self.skipWaiting(), activates, deletes old caches, and claims clients. Without this flow, users can keep an old app-cache-v1 long after you deployed a fix.
Use a cache name that includes a date, release number, or commit ID. If your build emits hashed files, keep the service worker precache list in sync with the build output. When manual cache lists become hard to maintain, Workbox is worth considering. Workbox automates the plumbing, but it still cannot decide which business data is safe to cache.
Offline UX Is Product Work
Offline support is not done when a response comes from Cache API. Users need to know whether their work is saved, waiting to sync, or failed. For forms, do not store POST requests in Cache API. Save drafts or pending jobs in IndexedDB from the page, then retry when the browser is online again. Background Sync can help, but browser support varies, so important flows should also include an online listener and a visible retry button.
When briefing Claude Code, include offline copy, retry controls, draft states, and sync-failure messages. A field app usually needs at least three states: sent, saved on this device, and sync failed.
Common Failure Cases
Scope mismatch is the first common problem. A worker at /app/sw.js controls /app/ by default, not the whole site. If you want full-site control, put the worker at /sw.js and register it with scope /.
cache.addAll() failures are next. If one URL in the list returns 404, the whole install fails. After Claude Code adds files, check DevTools Application panels for registration state and Cache Storage contents.
The third failure is private data. Do not cache /api/me, billing pages, admin HTML, or user-specific JSON unless you have a very deliberate reason and deletion path. Browser cache is still storage on a user’s device, and shared devices make stale personal data visible.
The fourth failure is skipping update UX. Old workers can hold old JavaScript and CSS. Version your cache names, delete old caches during activate, and let users reload when a new worker is waiting.
Finally, service workers are not permanent storage. Browsers may evict caches under pressure. Opaque cross-origin responses can be hard to size and debug. Service workers cannot access the DOM. They run only on HTTPS or localhost. Promise less than “complete offline support” unless you have tested the real workflow.
Summary and CTA
A service worker can improve repeat visits, offline behavior, and PWA quality. The safe path is to decide cache ownership, update lifecycle, private-data rules, and offline screens before asking Claude Code to edit files.
ClaudeCodeLab can help with PWA conversion, cache design, offline forms, Workbox migration, and Claude Code implementation reviews. If your site needs to become faster without serving stale or private data, start from Claude Code training and consulting.
When this minimal setup is tested in local Chrome, the Application panel shows claude-sw-demo-2026-06-02-v1 after the first load. Switching Network to Offline and reloading displays offline.html. Changing CACHE_VERSION causes old caches to be deleted during activate, which makes it a practical starting point for release verification.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.