Claude CodeでService Workerを安全に実装する実践ガイド
Service Workerの仕組み、キャッシュ、更新、オフラインUXをClaude Codeで実装する実践ガイド。
Service Workerは、PWAやオフライン対応を作るときに避けて通れない技術です。ただし「とりあえずキャッシュする」だけで入れると、古い画面が残る、ログイン後の情報を保存してしまう、更新しても反映されない、という事故が起きます。
この記事では、Service Workerを「ブラウザとサーバーの間にいる小さな代理人」として説明し、Claude Codeに実装を任せる前に決めること、コピペで動く最小コード、キャッシュ更新、オフラインUX、失敗例までまとめます。単なる用語解説ではなく、公開サイトや業務アプリに入れる前の判断材料として読める形にします。
公式仕様を確認するときは、MDNのService Worker API、web.devのService Worker解説、web.devのキャッシュ方針、本格運用で便利なChrome Workbox docsを併読してください。PWA全体の流れはClaude CodeでPWAを実装するガイド、キャッシュ層の考え方はClaude Codeのキャッシュ戦略、下書きや送信キューはIndexedDB実装ガイドとつながります。
Service Workerとは何か
Service Workerは、Webページとは別に動くJavaScriptです。ページのDOM、つまり画面上のボタンやフォームを直接触ることはできません。その代わり、ブラウザがネットワークへリクエストを出す前に割り込み、「これはキャッシュから返す」「これはネットワークを優先する」「通信できないのでoffline.htmlを返す」と判断できます。
普通のJavaScriptはタブを閉じると終わりますが、Service Workerはイベントが来たときだけ起動する裏方です。fetchイベントで通信を処理し、installイベントで初期キャッシュを作り、activateイベントで古いキャッシュを消します。対応ブラウザではPush通知やBackground Syncにも関われますが、まずはfetch、Cache API、更新ライフサイクルを正しく理解することが先です。
sequenceDiagram
participant User as ユーザー
participant Page as Webページ
participant SW as Service Worker
participant Cache as Cache API
participant Net as サーバー
User->>Page: ページを開く
Page->>SW: /sw.js を登録
Page->>SW: fetchリクエスト
SW->>Cache: キャッシュ確認
alt キャッシュあり
Cache-->>SW: 保存済みレスポンス
else キャッシュなし
SW->>Net: ネットワーク取得
Net-->>SW: 最新レスポンス
end
SW-->>Page: 表示するレスポンス
重要なのは、Service Workerは「高速化の魔法」ではなく「通信の交通整理」だという点です。何を保存し、いつ捨て、失敗時に何を見せるかを決めないまま入れると、速くなるより先に壊れます。
向いているユースケース
実務で価値が出やすい場面は少なくとも4つあります。
| ユースケース | 効く理由 | 注意点 |
|---|---|---|
| 技術ブログやドキュメント | 記事、CSS、画像、フォントを再訪時に速く表示できる | HTMLを長く固定すると更新が見えなくなる |
| SaaSのダッシュボード | サイドバーや静的アセットをキャッシュし、弱い回線でも骨格を表示できる | 個人情報や請求情報を共有キャッシュに入れない |
| 現場入力フォーム | 圏外でもofflineページや下書き保存で作業継続できる | POSTはCache APIに保存せずIndexedDBのキューで扱う |
| ECやメディアのカタログ | 商品画像やサムネイルの再取得を減らせる | 価格、在庫、権限付き画像は必ず鮮度を管理する |
Masaが小さな教材サイトで試したときは、画像とフォントだけをCache Firstにした段階で再訪の体感はかなり軽くなりました。一方で、記事HTMLまで無条件にCache Firstにした版では、公開後の誤字修正が読者に届かない問題が起きました。Claude Codeに頼むときも「全部キャッシュ」ではなく、対象と寿命を先に渡すほうが安全です。
Claude Codeに渡す前提
Claude Codeへ投げる依頼は、ファイル名だけでなく禁止事項と検証方法まで含めます。Service Workerは影響範囲が広いので、曖昧な依頼ほど危険です。
既存のViteアプリにService Workerを追加してください。
要件:
- /sw.js を public 直下に置き、scope は / にする
- GETの静的アセットだけをキャッシュする
- HTMLナビゲーションはNetwork Firstにして、失敗時は /offline.html を返す
- API、POST、認証ページ、外部オリジンはキャッシュしない
- キャッシュ名に日付またはバージョンを入れる
- activateで古いキャッシュを削除する
- 更新版が waiting になったらユーザーへ再読み込み確認を出す
検証:
- Chrome DevToolsのApplication > Service Workersで登録状態を確認
- NetworkをOfflineにして /offline.html が出るか確認
- CACHE_VERSIONを変えて古いキャッシュが削除されるか確認
この粒度にすると、Claude Codeが「動くが危ない」コードを書きにくくなります。特にAPI、POST、認証ページをキャッシュしない指定は必ず入れてください。
コピペで動く最小実装
次の例は、フレームワークなしで動くService Workerデモです。sw-demoのような空ディレクトリに4ファイルを置き、ローカルサーバーで開きます。Service WorkerはHTTPSかlocalhostでのみ動くため、ファイルを直接ダブルクリックしても登録されません。
python -m http.server 5173
ブラウザでhttp://localhost:5173を開きます。
<!-- index.html -->
<!doctype html>
<html lang="ja">
<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">登録処理を待っています。</p>
<button type="button" onclick="location.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(
"新しいバージョンがあります。今すぐ再読み込みしますか?",
);
if (ok) {
reloadRequested = true;
worker.postMessage({ type: "SKIP_WAITING" });
}
}
async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) {
setStatus("このブラウザはService Workerに対応していません。");
return;
}
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
setStatus(`Service Worker登録済み: ${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登録に失敗しました。");
}
}
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="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>オフラインです</title>
</head>
<body>
<main>
<h1>オフラインです</h1>
<p>通信が戻ったら、このページを再読み込みしてください。</p>
<button type="button" onclick="location.reload()">再試行</button>
</main>
</body>
</html>
この実装は、ナビゲーションをNetwork First、CSSやJSや画像をStale While Revalidateにしています。Network Firstは、まずサーバーへ取りに行き、失敗したときだけキャッシュやofflineページに戻す方針です。Stale While Revalidateは、手元のキャッシュをすぐ返しながら裏で新しいレスポンスを取り直します。ニュース、価格、ログイン後の画面など鮮度が重要なものには安易に使わないでください。
更新ライフサイクルとキャッシュ破棄
Service Workerで一番つまずきやすいのは更新です。ブラウザはsw.jsの内容が変わると新しいWorkerをインストールしますが、既存ページが開いたままだと新しいWorkerはwaiting状態で待ちます。そこで上のregister-sw.jsは、更新を検知したらユーザーに確認し、同意された場合だけSKIP_WAITINGを送ります。
sw.js側では、メッセージを受けてself.skipWaiting()を呼びます。その後activateで古いキャッシュを削除し、clients.claim()で制御を引き継ぎます。ここまでを入れないと、コードをデプロイしても読者は古いapp-cache-v1を持ち続けることがあります。
キャッシュ名はapp-cache-v1のような固定値ではなく、2026-06-02-v1やGitコミットIDを含めるのが実務向きです。HTML、CSS、JSのビルドファイル名にハッシュが付く環境なら、Service Worker側のプレキャッシュ一覧もビルド結果と同期させます。手書き管理がつらくなったらWorkboxを検討してください。ただしWorkboxを使っても「何をキャッシュしてよいか」は自分で決める必要があります。
オフラインUXは画面設計まで含める
オフライン対応は、キャッシュの成功だけでは完了しません。ユーザーに「今は保存できない」「下書きは端末に残っている」「通信が戻ったら再送する」と伝える必要があります。フォーム送信を扱うなら、POSTをCache APIへ入れるのではなく、ページ側でIndexedDBに下書きや送信予定を保存し、復帰時に再送します。Background Sync APIは便利ですが、ブラウザ差があるため、重要な業務ではonlineイベントと手動再試行ボタンも用意します。
Claude Codeへ依頼するときは、「offline.htmlを作って」だけでなく、表示文言、再試行ボタン、下書き保存の有無、同期失敗時のメッセージまで渡すと品質が上がります。現場アプリなら「送信済み」「端末に保存済み」「同期失敗」の3状態を分けるだけで、問い合わせがかなり減ります。
よくある失敗例
最初に多い失敗はscopeの不一致です。/app/sw.jsに置いたWorkerは、設定しない限り/app/配下しか制御できません。サイト全体を扱うなら/sw.jsをルートに置き、登録時のscopeも/にします。
次に、cache.addAll()へ存在しないファイルを入れる失敗があります。1つでも404があるとinstall全体が失敗します。Claude Codeにファイル追加を任せた後は、DevToolsのApplicationタブでinstall状態とCache Storageを必ず見ます。
三つ目は認証情報の扱いです。/api/me、請求ページ、管理画面HTML、ユーザー固有のJSONをキャッシュすると、ログアウト後に古い個人情報が表示されることがあります。Service Workerのキャッシュはブラウザ内の保存領域なので、共有PCやアカウント切り替えのシナリオまで考える必要があります。
四つ目は更新通知を省くことです。古いService Workerが残ると、修正済みのJavaScriptやCSSがなかなか反映されません。updatedDateやデプロイIDをキャッシュ名に入れ、activateで古いキャッシュを削除し、ユーザーに再読み込みのタイミングを渡してください。
最後に、Service Workerは万能ではありません。ブラウザは保存容量が足りなくなるとキャッシュを削除することがあります。外部CDNの不透明レスポンスはサイズ管理が難しい場合があります。DOMは触れません。HTTPSかlocalhostでしか動きません。これらを知らずに「オフライン完全対応」と書くと、実装と期待がずれます。
まとめと相談導線
Service Workerは、オフライン対応、再訪速度、PWA体験を強く改善できます。ただし価値を出すには、キャッシュ対象、更新ライフサイクル、認証情報の扱い、オフライン時の画面設計を先に決める必要があります。Claude Codeは実装速度を上げてくれますが、判断の枠組みを渡さないと危ないコードも速く作れてしまいます。
ClaudeCodeLabでは、既存サイトのPWA化、Service Workerのキャッシュ設計、オフラインフォーム、Workbox移行、Claude Codeを使った実装レビューの相談を受けています。自社サイトで「速くしたいが、古い情報や個人情報の事故は避けたい」場合は、Claude Code研修・開発相談から相談してください。
この記事で紹介した最小構成をローカルのChromeで試すと、初回表示後にApplicationタブへclaude-sw-demo-2026-06-02-v1が作られ、NetworkをOfflineにして再読み込みするとoffline.htmlが表示されます。CACHE_VERSIONを変えると古いキャッシュがactivate時に削除されるため、更新検証の出発点として使えます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code権限セーフティラダー: 初心者がallowを広げる順番
Claude Codeの権限をread-onlyからbuild、限定編集、deploy確認まで段階的に広げる安全な運用手順。
Claude Code Small PR Proof Pack: 小さなPRをレビュー可能にする証拠セット
Claude Codeの小さなPRに、差分・検証・公開URL・CTA・rollbackを添える実務チェックリスト。
Claude Codeのコミット前レビューゲート: 差分、テスト、CTAをまとめて止める型
Claude Codeでcommit前に差分をレビューする実践手順。build、公開URL、CTA、Gumroadリンク、未翻訳本文を検知します。