症状

あるサイトを Vue 2 / Nuxt 2 の SPA から、Service Worker を持たない別フレームワーク(移行先のフレームワーク)に載せ替えました。デプロイ自体は成功し、CDN のキャッシュも purge 済み。新しい URL を直接踏むと、新しい HTML が返ってきます。

ところが、運用を始めてしばらくすると次のような非対称な見え方の報告が上がってきました。

アクセス経路表示される内容
旧サイトを過去に開いたことのある PC のブラウザ旧 HTML(移行前のもの)
同じブラウザのシークレットウィンドウ新 HTML
別の端末・別のブラウザ新 HTML
別ドメインから同じバックエンドを叩く検証環境新 HTML

サーバ側のログを見ても、リピーターからのリクエストが極端に少ない。返事の中身がブラウザのどこかで「先回り」されています。これは典型的に Service Worker(以下 SW)の cache-first が効いている挙動でした。

原因

旧サイトは @nuxtjs/pwa を有効にしていたため、/sw.jsworkbox 製の SW が register 済みでした。@nuxtjs/pwa のナビゲーションリクエストの既定戦略は NetworkFirst(オフライン時のみキャッシュにフォールバック)ですが、ビルド成果物(/_nuxt/* 配下のハッシュ付き JS / CSS / 画像など)は precache に積まれて CacheFirst で配信されます。SW がインストールされた瞬間に、これらが Cache Storage に焼き付きます。

ナビゲーションが NetworkFirst でも、ネットワークタイムアウトや一時的な失敗 が起きると即座に旧 HTML が返る経路があり、加えて precache されたバンドルは古いものが返り続けます。結果として:

  1. SW が fetch イベントを intercept
  2. ナビゲーションは NetworkFirst だが、precache 対象は CacheFirst
  3. 状況によりキャッシュが返り、ユーザにとっては 「新サイトに更新されない」 ように見える

サーバ側で HTML を入れ替えてもブラウザに届かない、あるいは届いても古い JS と組み合わさって意図しない挙動になります。

なぜ Nuxt 2 で起こりがちか

@nuxtjs/pwanuxt.config.jsmodules に 1 行足すだけで PWA 化できる便利モジュールでした。manifest, meta, icons, workbox の 4 サブモジュールが内包されており、workbox は何も設定しなくてもデフォルト ON。SW の生成・登録・キャッシュ戦略まで暗黙で組み込まれます。

なお Nuxt 2 自体は 2024-06-30 で EOL を迎えており、当時の @nuxtjs/pwa はそのまま Nuxt 3 系には移植されず、最終リリースは v3.3.5(2021-01)で止まっています。後継的な位置づけでは別系統の @vite-pwa/nuxt が事実上の標準になりました。

近年のフレームワークでは状況が違います。

フレームワークSW の扱い
Nuxt 2 + @nuxtjs/pwaデフォルトで workbox 込みの SW が auto-register(autoRegister: true)
Nuxt 3 / 4 + @vite-pwa/nuxtモジュールを入れるかどうか自体が opt-in。入れた場合は既定で SW を生成・登録するが、Nuxt の標準には含まれない
Next.js (App Router)標準では SW を提供しない。Serwist (@serwist/next) などを別途入れて opt-in(Next.js 公式ガイドの推奨経路)
SvelteKit / Astro標準では SW を吐かない

つまり Nuxt 2 から先に挙げたいずれかのフレームワークへ移行すると、移行先には SW を吐く仕組みがデフォルトで無いため、何もしなければ /sw.js の新規生成は止まります。「もう PWA はやめた」と思っているのに、ユーザのブラウザの中だけは Nuxt 2 時代の SW がまだ生きている、という非対称が生じます。

補足: かつて Next.js でよく使われていた next-pwa(shadowwalker 版)は 2022 年末で更新が止まっており、後継として @ducanh2912/next-pwa フォークを経て、現在は Serwist が Next.js 公式ドキュメントから案内されています。

なぜ自然消滅しないか

/sw.js を消せばいいのでは」と思いがちですが、これだけでは退役しません。Service Worker の仕様上のポイントが二つあります。

  1. SW は 同一オリジンに永続化 されており、ブラウザを閉じても、PC を再起動しても消えません。明示的に消えるのは、ユーザが「閲覧履歴の削除 → サイトデータ」を踏んだ場合や、サーバが Clear-Site-Data: "storage" ヘッダを返した場合、ブラウザのストレージ逼迫時に LRU eviction が走った場合などに限られます。
  2. ブラウザは scope 内へのナビゲーション等を契機に /sw.js を取り直して update チェック します(古くは「24 時間に 1 回はチェックする」ように HTTP キャッシュの max-age が仕様で 24h に丸められていました。Chrome 68 以降は updateViaCache: 'imports' がデフォルトとなり、トップレベル SW スクリプトについては HTTP キャッシュをそもそも見ない挙動が標準です)。いずれにせよ、新スクリプトの fetch が 200 OK かつ JS の MIME タイプ で返らない限り、ブラウザは 既存 SW を温存します。404 や HTML を返している状態では何度 update を走らせても入れ替わりません。

つまり旧 /sw.js を削除した状態では、ユーザのローカルにある旧 SW はそのまま現役で走り続けます。CDN を purge しても、ドメインの DNS を切り替えても、ブラウザのキャッシュではなく SW のランタイムにロジックが残存しているため、サーバ側の対応では手が出せません。

解決策: kill-switch Service Worker

採れる手は一つで、「自身を unregister() し、Cache Storage を全消去したうえで退役する」だけの SW を、旧 SW と同じ URL に上書き配信することです。これを kill-switch SW、あるいは self-destructing SW と呼びます。

旧 SW の update チェックでこの新スクリプトが拾われ、installactivate が走り、最終的に registration.unregister() で自身を登録解除します。

// /sw.js — kill-switch Service Worker
// 旧 PWA SW を退役させる目的でのみ存在する。新規キャッシュは一切しない。

self.addEventListener('install', (event) => {
  // 待機中のクライアントを待たず、即座に新 SW を活性化する
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    // 1. Cache Storage を全削除(workbox-precache-* も含めてすべて)
    const keys = await caches.keys();
    await Promise.all(keys.map((k) => caches.delete(k)));

    // 2. この SW 自身を登録解除
    //    これ以降、この origin に SW は紐づかない
    await self.registration.unregister();

    // 3. 既に開かれているタブをハードリロードして、
    //    SW を介さない素のネットワーク経由で新サイトを取り直させる。
    //    `includeUncontrolled: true` が無いと、新 SW はまだ
    //    どのクライアントも control していない(skipWaiting しただけで
    //    clients.claim していないため)ので、配列が空になりがち。
    const clients = await self.clients.matchAll({
      type: 'window',
      includeUncontrolled: true,
    });
    for (const client of clients) {
      // navigate は same-origin のみ可。それ以外は try/catch で無視する
      try { client.navigate(client.url); } catch (_) { /* noop */ }
    }
  })());
});

// fetch ハンドラを登録しないことが重要。
// 何も intercept しないので、リロード後のリクエストは素通りで新サイトに届く。

ポイントは三つあります。

  • installskipWaiting() を呼ぶこと。これが無いと、開いているタブを全部閉じるまで新 SW が active にならず、ユーザにとっては「いつ反映されるか分からない」状態になります。
  • fetch ハンドラを 書かない こと。書いてしまうと結局新しい SW が intercept を始めてしまい、退役の意味がありません。
  • client.navigate(client.url) で開いているタブを再ナビゲートさせること。unregister() だけでは現在開いているタブはまだ旧 SW に支配されたままなので、ユーザが手動でリロードしない限り治りません。さらに clients.matchAll には includeUncontrolled: true を渡すこと。新 SW は skipWaiting() で活性化したばかりで、まだどのタブも control していない状態なので、これを省くとデフォルト(includeUncontrolled: false)で配列が空になり、再ナビゲートが空振りします。

デプロイ手順

  1. 旧 SW と 完全に同じ URL に上記スクリプトを置く(例: /sw.js)。パスが 1 文字でも違うと旧 SW は更新しに来ません。
  2. レスポンスヘッダの Content-Typeapplication/javascript(または text/javascript)で、ステータスが 200 OK で返ることを確認。HTML フォールバックや 404 ページを返している状態だと、ブラウザは新 SW としてインストールしてくれません。
  3. CDN を挟んでいる場合、/sw.js の 404 レスポンスをキャッシュしてしまっていないかを確認し、必要なら invalidate する。
  4. Cache-Control は短めに。後述。

ハマりどころ

/sw.js が HTML の 404 を返している

移行先のフレームワークは /sw.js というパスを知らないので、SPA フォールバックで index.html を返してしまうケースがあります。これだと:

  • ステータスは 200 OK
  • Content-Type は text/html

となり、ブラウザは「JS じゃないからインストール失敗」と判断して旧 SW を温存します。先に静的ファイルとして /sw.js を物理配置しておく必要があります。

Cache-Control を強くしすぎる

/sw.js 自体が Cache-Control: max-age=31536000 のような長期キャッシュで配信されていると、(後述する更新経路に依存しますが)ブラウザの HTTP キャッシュに張り付いて update チェックでも新スクリプトが拾われない可能性があります。kill-switch SW を置く時は、保険として /sw.js のレスポンスを Cache-Control: max-age=0, must-revalidate 程度に下げておくのが安全です。

なお、Service Worker のトップレベルスクリプトについては、Chrome 68 以降の既定 updateViaCache: 'imports' の下では update チェック時に HTTP キャッシュをそもそも見ない仕様になっています(importScripts() 経由の依存スクリプトは 'imports' の名の通り HTTP キャッシュの対象です)。逆に古い登録(updateViaCache'all' だった頃の @nuxtjs/pwa 由来など)では、HTTP キャッシュに引きずられるケースが残るので、Cache-Control を緩めておく価値は依然あります。

kill-switch を消すのは早すぎないこと

退役処理が終わったあとも、kill-switch 自体は 数週間〜数か月 残しておきます。理由は単純で、ロングテールな再訪ユーザ(数か月ぶりに開いた人)のブラウザの中ではまだ旧 SW が動いているからです。彼らの初回再訪時に kill-switch SW がフェッチされる必要があり、その時点で /sw.js が消えていると(404 になっていると)退役プロセスが起動しません。

「もう取り尽くしたな」と判断できるまで(例えばアクセスログから旧 SW 経由のリクエストがゼロになるまで)残しておく方が安全です。

教訓

  • 一度 register された Service Worker は、フレームワーク側を入れ替えても勝手には消えない。これが本件の本質でした。
  • @nuxtjs/pwa のように「1 行で PWA」を提供してくれるモジュールは便利ですが、外す時のコストが register 時の手軽さに釣り合わないことを覚えておくべきです。
  • フレームワーク移行のチェックリストには、「旧 PWA Service Worker の退役計画」 を必ず一項目入れる。リダイレクト、リライト、SEO、サイトマップと同じ階層の重要さで扱う必要があります。
  • kill-switch SW は、技術的には何もしない 30 行ほどのファイルですが、過去に register した SW を確実に退役させる唯一の手段です。コードを書くより「同じ URL に置く」「200 / application/javascript で返す」「しばらく残す」という運用面のほうが本質でした。

同じ事象に遭遇した方の参考になれば幸いです。