TL

Service Workerのライフサイクルとキャッシュ戦略の内部

デプロイしたのに古いまま、が起きる理由が分かる。install/activate/waiting の状態遷移、制御権、skipWaiting や clients.claim、ナビゲーションプリロードの動作原理を内部から解きます。

応用Service WorkerPWAキャッシュブラウザオフライン最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.Service Worker は parsed → installing → installed(waiting) → activating → activated → redundant と遷移する。既存タブが残る間は新版が waiting で待たされ、これが「更新が反映されない」最大の原因。
  • 2.skipWaiting() は waiting フェーズを飛ばして即 activate するが、制御中ページの操作は既存版のまま。clients.claim() を呼んで初めて、既に開いているページの制御権を新版が奪い取る。
  • 3.ナビゲーションプリロードは activate と並行してナビゲーション要求を先行送信し、Service Worker の起動待ち時間を隠す。fetch ハンドラ内で event.preloadResponse を消費しないと無駄打ちになる。

なぜライフサイクルを正確に理解する必要があるのか

Service Worker(以下 SW)は、ページとネットワークの間に常駐してリクエストを横取りできる強力なプロキシです。基礎は PWA とサービスワーカー を前提にします。ところが実務で最も多いトラブルは「コードを直してデプロイしたのに、ユーザーには古い挙動のまま」というものです。これは SW のライフサイクルが安全側に倒した厳密な状態遷移で設計されているために起きます。挙動を運や気分ではなく仕様アルゴリズムとして理解すれば、更新事故はほぼ消せます。本記事はその状態遷移と制御権の移動を内部レベルで解きほぐします。

スコープと制御権:どのページを支配するか

SW を navigator.serviceWorker.register('/sw.js', { scope: '/' }) で登録すると、その SW はスコープ配下のすべてのナビゲーションを制御する候補になります。重要な原則が3つあります。

  • スコープはスクリプトの URL で頭打ち/js/sw.js を登録すると既定スコープは /js/ 配下に限られる。ルート全体を制御したいなら SW スクリプトをルート直下に置くか、Service-Worker-Allowed ヘッダで上限を引き上げる。
  • 登録と制御は別物:register に成功しても、そのページ自身は既定では制御されない。SW は「次にそのページを読み込んだとき」から制御を始めるのが原則で、初回ロードのページは SW なしで動く。
  • 制御は再読み込み境界で切り替わる:あるページがどの SW に制御されるかは、ナビゲーション(ページ遷移・リロード)のタイミングで決まる。開きっぱなしのタブの制御権は、明示操作がない限り途中で変わらない。
クライアントとは「制御対象」のこと

SW の文脈で client は、その SW が制御しうるページ(window client)や Worker を指します。clients.matchAll() で現在制御中のクライアント群を取得でき、client.navigate()postMessage で操作・通信します。「SW は複数タブを1つのインスタンスから束ねて見ている」と捉えると、後述の clients.claim() の意味が掴めます。

状態遷移:parsed から redundant まで

register すると、ブラウザは SW スクリプトを取得・パースし、SW インスタンスは次の状態を順に辿ります。registration.installing / .waiting / .active の3スロットと、各 SW の state プロパティで観測できます。

状態意味ここで走るイベント
parsedスクリプト取得・構文解析が完了
installingインストール処理中install(waitUntil で延長)
installed / waitingインストール完了。有効化を待機—(既存版が制御中なら待たされる)
activating有効化処理中activate(waitUntil で延長)
activated稼働中。fetch を横取りできるfetch / message / push 等
redundant置換・失敗で破棄

install イベントは「新版の初期化」、activate イベントは「旧版の後始末」を担当します。両者とも event.waitUntil(promise) に渡した Promise が解決するまで次の状態へ進みません。install 中に cache.addAll([...]) でプリキャッシュを行い、activate 中に古い世代のキャッシュを削除する、という非対称な役割分担がここで効いてきます。

const CACHE = 'app-v3';

self.addEventListener('install', (event) => {
  // install を waitUntil で延長し、プリキャッシュ完了まで installed に進ませない
  event.waitUntil(
    caches.open(CACHE).then((c) => c.addAll(['/', '/app.js', '/offline.html']))
  );
});

self.addEventListener('activate', (event) => {
  // activate で古い世代のキャッシュを一掃(後始末は新版が制御を握ってから)
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
    )
  );
});

waiting フェーズ:更新が止まる仕組み

ここが事故の核心です。既に旧版 SW がページを制御している状態で新版を register すると、新版は install まで進んで installed(waiting) で停止します。既存版が制御するクライアントが1つでも残っている限り、新版は activate に進めません。これは「ページ表示中に裏でプロキシがすり替わると、リソースのバージョン不整合(HTML は旧版・JS は新版など)が起きる」ことを防ぐための安全装置です。

つまり、SW を更新するには本来「そのオリジンのタブを全部閉じる」必要があります。リロードだけでは不十分なことが多い点に注意してください。多くのブラウザではリロード時に旧クライアントが一瞬残り、新版は waiting のままになります。

リロードでは更新されない理由

F5 リロードは「古いページをアンロードしてから新しいページをロード」しますが、この間に制御中クライアントの数がゼロにならないため、新版が activate するトリガーを満たしません。確実に切り替えるには全タブを閉じるか、後述の skipWaiting() を使います。「直したのに反映されない」の大半はこの待機仕様が原因です。

skipWaiting と clients.claim:制御を奪う2つの操作

待機をスキップして即座に新版へ切り替えたい場合、操作は2段階に分かれます。混同すると中途半端な状態になるため、役割を厳密に分けて理解します。

  • self.skipWaiting():waiting フェーズを飛ばし、新版を即 activating → activated へ進める。ただしこれだけでは、既に開いているページの制御権は旧版のまま。skipWaiting は「待機列の追い越し」であって「制御権の移譲」ではない。
  • self.clients.claim():activate 済みの SW が、現在制御されていない/旧版に制御されているクライアントの制御権を奪い取る。これを呼んで初めて、開きっぱなしのタブが次の fetch から新版に流れる。
呼び出し効果呼ばないと
skipWaiting()waiting を飛ばして即 activated に全タブを閉じるまで新版は待機
clients.claim()既存ページの制御権を新版へ移す既存タブは次のナビゲーションまで旧版のまま
両方今開いているタブを即・新版で制御
self.addEventListener('install', (event) => {
  // 待機をスキップして即 activate へ(install 完了直後に追い越す)
  self.skipWaiting();
  event.waitUntil(precache());
});

self.addEventListener('activate', (event) => {
  // activate 完了後、既存クライアントの制御権を即座に掌握
  event.waitUntil(self.clients.claim());
});
skipWaiting の副作用:バージョン不整合

skipWaiting で即切り替えると、現在表示中のページが、旧版が配ったアセットを前提にしているのに、新版が新しいアセットを返す、という不整合が起こり得ます。HTML とそれが参照する JS/CSS のバージョンがずれてエラーになる典型例です。安全に使うなら ❶ アセットをハッシュ付き URL で不変化し新旧を共存させる、❷ ユーザーに「更新があります」と通知し同意を得てから skipWaiting する、のいずれかを設計に組み込みます。無条件 skipWaiting は事故のもとです。

ナビゲーションプリロード:起動待ちを隠す

SW は常時メモリに常駐しているわけではなく、アイドル時に終了され、ナビゲーション発生時に再起動されます。問題は、ページ遷移のたびに SW を起動し、その fetch ハンドラが走り終えるまでネットワーク要求が始まらないと、SW 起動分の遅延がそのまま体感速度を悪化させることです。

ナビゲーションプリロード(Navigation Preload)はこれを解決します。registration.navigationPreload.enable() を有効にすると、ブラウザは SW の起動を待たず、ナビゲーション要求をネットワークへ先行送信します。SW の起動とサーバーへの往復が並行して走るため、起動時間が往復時間の陰に隠れます。

重要なのは、先行送信したレスポンスは event.preloadResponse(Promise)として fetch ハンドラに渡される点です。ハンドラ側でこれを消費しないと、先行送信は無駄打ちになります。

self.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.enable(); // activate で有効化
      }
    })()
  );
});

self.addEventListener('fetch', (event) => {
  if (event.request.mode !== 'navigate') return; // ナビゲーションのみ対象
  event.respondWith(
    (async () => {
      // 先行送信済みレスポンスを優先利用。無ければ自前で fetch
      const preloaded = await event.preloadResponse;
      if (preloaded) return preloaded;
      return fetch(event.request);
    })()
  );
});

enable() は活性化以降に有効になり、送信される要求には Service-Worker-Navigation-Preload ヘッダが付くため、サーバー側で「これはプリロード要求」と判別して応答を最適化できます。プリロードはナビゲーション要求(mode === 'navigate')にのみ効く点も押さえておきます。

キャッシュ戦略の使い分け

fetch ハンドラ内の応答方針は、SW が Cache StorageHTTP キャッシュ のブラウザ自動キャッシュとは別の、SW が明示制御する独立した保存域)とネットワークをどう組み合わせるかで決まります。

戦略動作向くリソース
Cache Firstキャッシュ→無ければネットワークハッシュ付き静的アセット(変わらない)
Network Firstネットワーク→失敗時キャッシュ最新性が要る API・記事
Stale-While-Revalidateキャッシュ即返し+裏で更新速度優先・次回更新で十分
Network Only常にネットワーク決済など鮮度必須・キャッシュ不可
// Stale-While-Revalidate:キャッシュを即返しつつ、裏で新しい応答を取得して更新
async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE);
  const cached = await cache.match(request);
  const fetching = fetch(request).then((res) => {
    cache.put(request, res.clone()); // 次回用に更新(応答は一度しか読めないので clone)
    return res;
  });
  return cached || fetching; // あれば即キャッシュ、無ければネットワークを待つ
}
試験・面接の頻出ポイント

頻出は次の4点です。❶ 状態遷移は installing → installed(waiting) → activating → activated → redundant、❷ 既存クライアントが残る間は新版が waiting で待機し、skipWaiting() が追い越す、❸ clients.claim() を呼ばないと既存タブの制御権は移らない、❹ ナビゲーションプリロードは SW 起動と並行して要求を先送りし、event.preloadResponse で消費する。skipWaiting と clients.claim の役割の違いを言語化できるかが分岐点です。

まとめ

まとめ

Service Worker は parsed → installing → installed(waiting) → activating → activated → redundant と遷移します。install で新版を初期化(プリキャッシュ)、activate で旧版を後始末(古いキャッシュ削除)するのが定石です。既存クライアントが残る間、新版は waiting で待たされる――これが「更新が反映されない」最大の原因です。skipWaiting() は待機を追い越して即 activate し、clients.claim() は既存ページの制御権を新版へ移す、という別々の操作を正しく組み合わせます。navigationPreload は SW 起動と並行してナビゲーション要求を先送りし、event.preloadResponse で消費することで起動遅延を隠します。SW が扱う Cache Storage はブラウザ自動の HTTP キャッシュ とは独立した保存域で、Cache First / Network First / SWR を用途で使い分けます。クライアント側の永続化全体は Web ストレージ と合わせて把握しておくと、保存域の住み分けが明確になります。

Web/フロントエンド Article

Service Workerのライフサイクルとキャッシュ戦略の内部を実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

Service Worker

比較で見る軸

難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5

導入後に効く点

skipWaiting() は waiting フェーズを飛ばして即 activate するが、制御中ページの操作は既存版のまま。clients.claim() を呼んで初めて、既に開いているページの制御権を新版が奪い取る。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
Web/フロントエンド
タグ数
5

判断チェックリスト

  • 自社の用途が「Service Worker / PWA」に近いか確認する。
  • 強みである「Service Worker は parsed → installing → installed(waiting) → activating → activated → redundant と遷移する。既存タブが残る間は新版が waiting で待たされ、これが「更新が反映されない」最大の原因。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

Service WorkerPWAキャッシュブラウザオフラインService WorkerPWAキャッシュ
参考: 公式情報