Service Worker入門:キャッシュ戦略3つとオフライン対応、更新の落とし穴
Service Workerのinstall/activate/fetch、cache-first・network-first・stale-while-revalidateの使い分け、Workbox、古いSWが残る更新事故をコピペコードで。
修正した誤字が、読者の画面に1週間届かなかったことがあります。
サイトを軽くしようとService Workerを入れて、勢いで「全部キャッシュ」にした翌週の話です。タイポを直してデプロイしても、僕の端末ではずっと古い文章のまま。キャッシュを消すまで気づきませんでした。ブラウザは新しいファイルを取りに行かず、手元に保存した古いHTMLを律儀に返し続けていたんです。
Service Workerは、使い方を間違えると「速くする道具」が「更新を止める道具」に化けます。逆に、登録の流れとキャッシュ戦略の使い分けさえ押さえれば、再訪はぐっと速くなり、圏外でも画面が出るようになります。今日はその境目を、僕が踏んだ地雷ごと共有します。
この記事の要点
- Service Workerはページとは別に動く裏方で、ブラウザの通信に割り込んで「キャッシュを返すか、サーバーに行くか」を決める
- ライフサイクルは
install(初期キャッシュ)→activate(古いキャッシュ掃除)→fetch(通信の交通整理)の3段階 - キャッシュ戦略は cache-first / network-first / stale-while-revalidate の3つを、対象ごとに使い分けるのがコツ
- 一番の事故は「古いSWが残って更新が届かない」。キャッシュ名にバージョンを入れ、
activateで消し、更新通知を出す - 手で管理がつらくなったらWorkbox。ただし「何をキャッシュしてよいか」だけは自分で決める
Service Workerは「通信の門番」
Service Workerは、Webページとは別のスレッドで動くJavaScriptです。一番大事な性質は2つ。
ひとつ、画面のDOMを直接いじれません。ボタンやフォームには触れない。その代わり、ブラウザがネットワークへリクエストを出す前に割り込んで、「これはキャッシュから返す」「これはサーバー優先」「圏外だから offline.html を返す」と判断できます。郵便物を仕分けする受付係をイメージすると近いです。
ふたつ、タブを閉じても死にません。普通のJavaScriptはページを閉じれば終わりですが、Service Workerはイベントが来たときだけ起きる裏方です。だからPush通知やBackground Syncのような「ページが開いていなくても動く」機能の土台にもなります。
つまりService Workerは高速化の魔法ではなく、通信の門番です。何を保存し、いつ捨て、つながらないとき何を見せるか。これを決めずに入れると、速くなる前に冒頭の僕のように壊れます。
| できること | できないこと |
|---|---|
fetch に割り込んでキャッシュ/通信を選ぶ | DOM(ボタン・入力欄)を直接触る |
| オフライン時に代替ページを返す | HTTPSやlocalhost以外で動く |
| Push通知・Background Syncの土台になる | 同期的なlocalStorageを使う |
登録ライフサイクル:install / activate / fetch
Service Workerの一生は、ざっくり3つのイベントで進みます。ここが分かると、更新事故の9割は理解できます。
- install:登録された直後に一度だけ走る。ここで「最初に持っておきたいファイル」をキャッシュに入れます。HTML・CSS・JS・オフライン用ページなど、サイトの骨格(App Shell)を保存する場所です。
- activate:installが終わり、いよいよ制御を握るときに走る。ここで古いバージョンのキャッシュを消すのが定番です。掃除をサボると古いファイルが残り続けます。
- fetch:ページが通信するたびに走る。リクエストごとに「キャッシュを返すか、サーバーに行くか」を決める、いわば営業時間中の門番です。
つまずきの正体はこの3つの「タイミング差」です。sw.js の中身が1バイトでも変わると、ブラウザは新しいService Workerを install します。でも、今開いているタブを古いSWがまだ握っている間、新しいSWは waiting(待機)状態で順番待ちになります。全タブを閉じるまで交代しません。これが「デプロイしたのに反映されない」の本当の理由です。
sequenceDiagram
participant Page as Webページ
participant SW as Service Worker
participant Cache as Cache API
participant Net as サーバー
Page->>SW: /sw.js を登録
SW->>Cache: install で App Shell を保存
SW->>SW: activate で古いキャッシュを削除
Page->>SW: fetch リクエスト
SW->>Cache: まずキャッシュを確認
alt 戦略しだいで分岐
Cache-->>SW: 保存済みレスポンスを返す
else サーバー優先 or 未保存
SW->>Net: ネットワーク取得
Net-->>SW: 最新レスポンス
end
SW-->>Page: 表示するレスポンス
キャッシュ戦略3つの使い分け
fetch で何を返すか、その方針が「キャッシュ戦略」です。代表的なのは3つ。全部を1つの戦略で塗りつぶすのが事故のもとで、対象ごとに変えるのが正解です。
| 戦略 | 動き | 向いている対象 | 注意 |
|---|---|---|---|
| cache-first | キャッシュがあれば即返す。無ければ取得して保存 | フォント、アイコン、ハッシュ付きの静的アセット | 更新されない前提のものだけ。HTMLに使うと事故る |
| network-first | まずサーバー。失敗したらキャッシュ/オフラインページ | HTMLナビゲーション、鮮度が要る画面 | 回線が遅いと表示も遅くなる |
| stale-while-revalidate | 手元のキャッシュを即返しつつ、裏で取り直す | CSS・JS・画像など「多少古くてもまず表示優先」 | ニュース・価格・ログイン後には使わない |
僕の失敗を当てはめると、原因は明確でした。記事HTMLを cache-first にしたから、更新が届かなかった。HTMLは network-first(つながらないときだけキャッシュ)にすべきで、cache-first は「ファイル名にハッシュが付いていて中身が変わらない静的アセット」専用です。この線引きを最初に決めておくと、Claude Codeに実装を任せるときも「全部キャッシュして」ではなく「画像はSWR、HTMLはnetwork-first」と具体的に渡せます。
ちなみにService Workerのキャッシュと、ブラウザ標準のHTTPキャッシュ(Cache-Control)は別物です。両方が効くので、二重にキャッシュして余計こんがらがることもあります。考え方はClaude Codeのキャッシュ戦略に整理したので、層ごとの責任分担はそちらも参照してください。
コピペで動く最小実装
理屈だけだと身につかないので、フレームワークなしで動く最小セットを置きます。空ディレクトリに4ファイルを作り、ローカルサーバーで開くだけです。Service WorkerはHTTPSかlocalhostでしか動かないので、HTMLファイルを直接ダブルクリックしても登録されません。必ずサーバー越しに開いてください。
# 適当な空フォルダで実行(Pythonがあれば一番手軽)
python -m http.server 5173
# ブラウザで http://localhost:5173 を開く
まずページ本体と登録スクリプト。register-sw.js は「更新を検知したらユーザーに再読み込みを聞く」役目を持たせています。
<!-- 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>
</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 statusEl = document.querySelector("#status");
let reloadRequested = false;
let updatePromptShown = false;
function setStatus(message) {
if (statusEl) statusEl.textContent = message;
}
// 新しいSWが待機状態になったら、ユーザーに再読み込みを確認する
function askToReload(worker) {
if (updatePromptShown) return;
updatePromptShown = true;
const ok = window.confirm("新しいバージョンがあります。今すぐ更新しますか?");
if (ok) {
reloadRequested = true;
worker.postMessage({ type: "SKIP_WAITING" }); // SWに「待たずに交代して」と伝える
}
}
async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) {
setStatus("このブラウザはService Workerに非対応です。");
return;
}
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/", // サイト全体を制御したいので scope はルート
});
setStatus(`登録済み: ${registration.scope}`);
// すでに待機中のSWがいれば、その場で確認
if (registration.waiting && navigator.serviceWorker.controller) {
askToReload(registration.waiting);
}
// 新しいSWが見つかったときの監視
registration.addEventListener("updatefound", () => {
const worker = registration.installing;
if (!worker) return;
worker.addEventListener("statechange", () => {
// installedになり、かつ既存の制御者がいる=更新版が待機に入った合図
if (worker.state === "installed" && navigator.serviceWorker.controller) {
askToReload(worker);
}
});
});
} catch (error) {
console.error(error);
setStatus("Service Workerの登録に失敗しました。");
}
}
// SWが交代したら、同意済みのときだけ1回リロード
navigator.serviceWorker?.addEventListener("controllerchange", () => {
if (reloadRequested) window.location.reload();
});
registerServiceWorker();
次が本体の sw.js。3つの戦略のうち、HTMLは network-first、CSS/JS/画像は stale-while-revalidate にしています。キャッシュ名に日付バージョンを入れているのがポイントで、ここが更新事故を防ぐ命綱です。
// sw.js
const CACHE_VERSION = "2026-06-07-v1"; // ★更新時はここを変える(命綱)
const CACHE_PREFIX = "claude-sw-demo";
const CACHE_NAME = `${CACHE_PREFIX}-${CACHE_VERSION}`;
// 最初に持っておく骨格ファイル(App Shell)
const APP_SHELL = ["/", "/index.html", "/offline.html", "/register-sw.js"];
// install: 骨格をまとめてキャッシュ
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
);
});
// activate: 自分以外の古いキャッシュを掃除する
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
.map((name) => caches.delete(name)),
),
),
);
self.clients.claim(); // 開いているページの制御をすぐ引き継ぐ
});
// ページからの「待たずに交代して」を受け取る
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
// fetch: リクエストの種類で戦略を切り替える
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return; // POSTなどはキャッシュしない
const url = new URL(request.url);
if (url.origin !== self.location.origin) return; // 外部オリジンは触らない
if (request.mode === "navigate") {
event.respondWith(networkFirst(request)); // HTMLはサーバー優先
return;
}
if (["style", "script", "font", "image"].includes(request.destination)) {
event.respondWith(staleWhileRevalidate(request)); // 静的アセットはSWR
}
});
// network-first: まずサーバー、ダメならキャッシュ→オフラインページ
async function networkFirst(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 })
);
}
}
// stale-while-revalidate: 手元を即返しつつ裏で更新
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>
<h1>オフラインです</h1>
<p>通信が戻ったら、このページを再読み込みしてください。</p>
<button type="button" onclick="location.reload()">再試行</button>
</body>
</html>
これで、初回表示後にChrome DevToolsのApplicationタブを開くと claude-sw-demo-2026-06-07-v1 というキャッシュができます。Networkを「Offline」にして再読み込みすると offline.html が出る。動きを見ると、上のライフサイクルの説明が一気に腹落ちします。
更新の落とし穴:古いSWが居座る
僕が一番時間を溶かしたのがここです。順を追うと事故の構造が見えます。
sw.js を書き換えてデプロイすると、ブラウザは新しいSWを install する。ところが既存タブを古いSWが握っている間、新しいSWは waiting のまま。ユーザーが全タブを閉じて開き直さない限り交代しない。結果、修正したJSやCSSが何日も届かない——これが冒頭の「誤字が1週間消えなかった」現象の正体でした。
対策は3点セットです。
- キャッシュ名にバージョンを入れる。
app-cache-v1のような固定名はNG。2026-06-07-v1やGitコミットIDを混ぜて、デプロイのたびに別名にします。名前が変われば古いキャッシュと別物になり、掃除の対象にできます。 activateで古いキャッシュを消す。上のコードのcaches.deleteがこれ。これが無いと古いファイルが永遠に残ります。- 更新通知を出す。
updatefoundで新SWを検知し、ユーザーに「更新しますか?」と聞いて、同意されたらSKIP_WAITINGを送って即交代させる。register-sw.jsでやっているのがこれです。
ビルドツールがファイル名にハッシュを付ける環境なら、APP_SHELL の一覧もビルド結果と同期させる必要があります。手で管理するのが限界になったら、次のWorkboxの出番です。
Workboxはいつ使うか
Workboxは、Googleが出しているService Worker用のライブラリです。上で手書きした「戦略の出し分け」「プレキャッシュ」「古いキャッシュの掃除」を、短い宣言で書けるようにしてくれます。
たとえば「画像は stale-while-revalidate、有効期限は30日で最大60件」みたいな設定が、数行で済みます。
// sw.js(Workbox利用版・要 workbox-* パッケージ)
import { precacheAndRoute } from "workbox-precaching";
import { registerRoute } from "workbox-routing";
import { StaleWhileRevalidate, NetworkFirst } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
// ビルド時に差し込まれるファイル一覧をまとめてプレキャッシュ
precacheAndRoute(self.__WB_MANIFEST);
// HTMLナビゲーションは network-first
registerRoute(
({ request }) => request.mode === "navigate",
new NetworkFirst({ cacheName: "html" }),
);
// 画像は stale-while-revalidate(30日 / 最大60件で自動削除)
registerRoute(
({ request }) => request.destination === "image",
new StaleWhileRevalidate({
cacheName: "images",
plugins: [new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 })],
}),
);
判断の目安はシンプルです。プレキャッシュ一覧の手書き管理がつらくなったら入れる。ファイル数が少ない静的サイトなら、手書きの sw.js のほうが見通しがよく、依存も増えません。逆にビルドのたびに大量のハッシュ付きファイルが生まれるアプリでは、__WB_MANIFEST の自動生成が効きます。注意点は1つだけ。Workboxを入れても「何をキャッシュしてよいか」の判断は肩代わりしてくれません。価格・在庫・個人情報をうっかりキャッシュする事故は、ライブラリの有無に関係なく起きます。
オフライン対応は画面設計まで
キャッシュが成功すれば完了、ではありません。ユーザーに状態を伝えるところまでがオフライン対応です。
特にフォーム送信。POSTレスポンスをCache APIに入れるのは筋が悪いので、ページ側で IndexedDB に下書きや送信待ちを保存し、復帰時に再送します。この下書きキューの作り方はIndexedDB実装ガイドに書きました。Background Sync APIを使えば自動再送もできますが、ブラウザ差があるので、業務で使うなら online イベントと手動の再試行ボタンを保険に置きます。
現場の入力アプリなら、「送信済み」「端末に保存済み」「同期失敗」の3状態を画面で出し分けるだけで問い合わせが激減します。PWA全体の組み立て方はClaude CodeでPWAを実装するガイドにまとめてあるので、マニフェストやインストール導線まで含めて設計したい人はそちらへ。
よくある質問
Q. Service Workerが登録されません。
A. まずHTTPSかlocalhostで開いているか確認してください。file://では動きません。次にsw.jsのパスとscopeの一致を確認。/app/sw.jsは基本/app/配下しか制御しません。サイト全体なら/sw.jsをルートに置きます。
Q. デプロイしたのに古い画面のままです。
A. 古いSWがwaitingで残っている典型です。キャッシュ名にバージョンを入れ、activateで古いキャッシュを削除し、更新通知からSKIP_WAITINGを送る3点セットを入れてください。検証中はDevToolsの「Update on reload」も便利です。
Q. cache-firstとstale-while-revalidateはどう違いますか。 A. cache-firstはキャッシュがある限りサーバーに行きません。SWRはキャッシュを即返しつつ裏で取り直し、次回は新しいものになります。「絶対変わらない」ならcache-first、「多少古くてもいいがいずれ更新したい」ならSWRです。
Q. POSTやAPIレスポンスもキャッシュすべき? A. しないでください。POSTはCache APIで扱わずIndexedDBのキューへ。認証付きのJSONや個人情報をキャッシュすると、ログアウト後や共有PCで古い情報が漏れます。
Q. 最初からWorkboxを使うべき? A. ファイル数が少なければ手書きで十分です。ビルドで大量のハッシュ付きファイルが出る規模になってから入れると、プレキャッシュ管理が楽になります。
実際に試した結果
冒頭の「誤字が1週間消えなかった」事故のあと、僕がやったのは大改造ではありません。キャッシュ名に日付を入れ、HTMLを cache-first から network-first に変え、activate の掃除と更新通知を足した。それだけで、デプロイした修正が次の再読み込みで届くようになりました。
体感の効果も書いておきます。画像とフォントをSWRにした段階で、再訪時の表示は明らかに軽くなりました。一方、記事HTMLに鮮度の戦略を当てたことで、更新が止まる事故はゼロに。「全部キャッシュ」をやめて対象ごとに戦略を分けただけで、速さと鮮度は両立しました。
最後に判断の枠組みを渡す話を。Claude Codeに実装を頼むと速いですが、「Service Worker入れて」だけだと、動くけど危ないコードも一瞬で出てきます。「HTMLはnetwork-first、静的アセットはSWR、POSTと認証はキャッシュ禁止、キャッシュ名にバージョン」——この4点を先に渡すだけで、事故率がぐっと下がります。判断は人、実装はAI。この分担がいちばん速い、というのが今の実感です。
仕様の一次情報はMDNのService Worker API、戦略の整理はweb.devのService Worker解説、運用はChrome Workbox docsが頼りになります。既存サイトのPWA化やキャッシュ設計で「速くしたいが古い情報や個人情報の事故は避けたい」なら、Claude Code研修・開発相談から気軽にどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。