Service Worker — 直したのに、利用者の画面だけ古い
修正をデプロイしたのに、一部の利用者だけ古い画面のまま壊れた API を叩き続ける。ブラウザとネットワークの間に、自分で書いたプロキシがもう1枚挟まっている。
この記事の進み方
What — ページとネットワークの間に挟まるもの
Service Worker は、ページが出すリクエストを横取りして、応答を自分で決められるスクリプトです。ページとは別のスレッドで動き、ページが閉じていても登録されたまま残ります。
普通のリクエストは、ページからブラウザの HTTP キャッシュを経てネットワークに出ます。Service Worker を登録すると、その手前にもう1枚、自分で書いたプロキシが入ります。
ページはいつもどおり fetch しているだけ。応答がキャッシュから来たのかネットワークから来たのか、ページには区別がつかない。古い HTML が返っても、ページは気づけない。
- Service Worker
- ページとネットワークの間でリクエストを横取りするスクリプト。オリジンとパス(スコープ)ごとに1つ登録される。
- Cache Storage
- Service Worker から読み書きするキャッシュ。HTTP キャッシュとは別で、有効期限が無く、自分で消すまで残る。
- クライアント
- Service Worker が制御しているページ(タブ)。1つの Service Worker が複数のタブを同時に制御する。
- スコープ
- Service Worker が横取りできるパスの範囲。既定では sw.js を置いたディレクトリ以下。
新しい版は、すぐには動かない
冒頭の事故で「一部の利用者だけ」だった理由は、Service Worker の更新の仕組みにあります。
ブラウザはページを開くたびに sw.js を取りに行き、1バイトでも違えば新しい版として扱います。ただし、新しい版はすぐには有効になりません。
旧版が稼働中旧版開いているタブをすべて旧版が制御している
新版をインストール中新版install イベントで新しいキャッシュを準備している
新版が待機中新版旧版が制御しているタブが無くなるのを待っている
新版が稼働中新版以降に開いたタブを新版が制御する
旧版が稼働中—ページを開いたとき sw.js が変わっていた→新版をインストール中 — ブラウザがナビゲーションのたびに sw.js を取りに行き、中身を1バイト単位で比べる。違えば新版のインストールが始まる。新しい版は「待機」で止まる。古い版が制御しているタブが1枚でも残っていれば、そのまま待ち続ける。再読み込みでは抜けられない。
新しい sw.js をデプロイしました。開いたままのタブを再読み込みしても、古い版の Service Worker が応答し続けます。なぜですか?
Why — なぜ待たせるのか
新しい版をすぐ有効にしない理由は、1つのページの中で版が混ざるのを防ぐためです。
ページはすでに旧版の HTML と JS を読み込んで動いています。ここで制御者が新版に入れ替わると、そのページがあとから要求する画像や JS は、新版のキャッシュから返ります。旧版の JS が app.3f2a.js の続きとして chunk.3f2a.js を要求したとき、新版はもうそのファイルを持っていないかもしれません。
12:00 旧版の HTML と app.3f2a.js を読み込んで画面が動いている
12:05 新版に切り替わる(新版のキャッシュは app.9c1d.js 用)
12:06 画面遷移で chunk.3f2a.js を要求 → 新版は持っていない
→ ネットワークにも無い(デプロイで消えた)→ 画面が白くなる
「待機」は、この食い違いを起こさない代わりに、更新を遅らせるという取引です。仕様はこちらを既定にしました。
キャッシュ優先で HTML を返すと、更新の入口が塞がる
もう1つの原因は、冒頭で HTML をキャッシュ優先にしていたことです。
ハッシュ付きのファイル名(app.3f2a.js)は、中身が変わればファイル名も変わるので、キャッシュ優先で返しても古いものが混ざりません。HTML は違います。 index.html は名前を変えずに中身が変わり、しかも「どの JS を読むか」を決めている入口です。
HTML をキャッシュから返すと、ページは古い入口から古い JS を読みます。新版の Service Worker が有効になるまで、サーバーに何を置いても届きません。
強制再読み込みで、一度だけ直る
冒頭の「一度直ったのに、翌朝また古い画面に戻った」はこれです。
Shift を押しながらの再読み込み(強制再読み込み)は、その1回だけ Service Worker を通さずにネットワークから取ります。新しい HTML が見えるので直ったように見えますが、登録もキャッシュもそのままです。次に普通に開くと、また横取りされて古い HTML が返ります。
「再読み込みで直った」は、直ったことの証拠になりません。 手元で確かめるときほど、ここで騙されます。
速いが、自分で消すまで古いものを返し続ける
caches.match(req) ?? fetch(req)- 速さ
- 最速
- オフライン
- 開ける
- 古さ
- **消すまで無期限**
- 向いているもの
- ハッシュ付きの JS・CSS・フォント
- –ファイル名に中身のハッシュが入っていれば、古いものが混ざらない
- –HTML に使うと、更新の入口が塞がる。冒頭の事故の原因
- –Cache Storage は Cache-Control の有効期限では消えない
戦略はリクエストの種類ごとに選ぶ。サイト全体に1つの戦略を当てはめると、どこかで必ず古いものが返るか、オフラインで開けなくなる。
消したつもりでも、消えない
事故のあと、「Service Worker をやめよう」と sw.js をサーバーから削除したくなります。これでは消えません。
ブラウザは更新確認で sw.js を取りに行き、404 が返ると更新に失敗したと判断し、いま動いている版を使い続けます。サーバーの一時的な障害で Service Worker が消えてしまわないための振る舞いです。
回収するには、同じ URL に「自分を登録解除してキャッシュを消す版」を置く必要があります。
// /sw.js — 回収用の版
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
for (const key of await caches.keys()) await caches.delete(key);
await self.registration.unregister();
// 開いている画面は古いまま。読み直させて、横取りの無い状態で開き直す
const windows = await self.clients.matchAll({ type: "window" });
for (const client of windows) client.navigate(client.url);
})());
});
問題のある Service Worker を消そうと、sw.js をサーバーから削除しました。利用者の手元では何が起きますか?
演習 — まず自分で判断する
解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。
この在庫確認アプリで、Service Worker のキャッシュ戦略をどう組みますか?
- 倉庫で使う。電波が弱い場所があり、画面が開かないと作業が止まる
- 配信しているのは HTML、ハッシュ付きの JS と CSS、商品画像、在庫数の API
- 在庫数が古いまま表示されると、無い商品を出荷指示してしまう
- 週に数回デプロイする。API を変えるときは画面も同時に変わる
- 端末は共用で、タブを開きっぱなしにしていることが多い
- · リクエストの種類ごとに、古いものが返ったら何が起きるか
- · オフラインで開けることと、最新であることのどちらを優先するか
- · タブを閉じない運用で、新しい版がいつ届くか
「キャッシュをクリアしてください」と案内した問い合わせ対応の担当者に、なぜそれで直る人と直らない人がいたのかを説明してください
- 相手はブラウザのキャッシュという言葉は知っているが、Service Worker は知らない
- 次に同じ問い合わせが来たときに、何と案内すればよいかを知りたがっている
- 開発側で直すまでの数日間をしのぎたい
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。