ほぼ黒のカードに白のPWAロゴ。P・W・Aが線を共有しながら一続きの角ばったロゴタイプになっている

Service Worker が記事一覧を1回分古いまま返していた — 一覧だけネットワークを先に見る

Astro の Service Worker がすべてのナビゲーションをキャッシュ優先で返していたため、新着記事が1回分遅れて出ていました。一覧ページはネットワークを先に見るようにしています。

目次

はじめに

記事を公開してブログを開いたのに、一覧に出てきませんでした。再読み込みすると出てきます。毎回そうなるので、サイトが自分のトップページのキャッシュを返しているのだろうと見当をつけました。オフライン閲覧 を 0.32.0 で出したのが2日前だったので、タイミングはそれと一緒に入った Service Worker を指していました。実際そのとおりで、ワーカーはすべてのナビゲーションに1つのキャッシュ戦略を当てており、その中には「何があるかを並べる」ことだけが仕事のページも含まれていました。 本記事では、その戦略が記事には正しく一覧には誤りである理由、成立しない2つの修正、そしてコードレビューが捕まえるまで元のバグを恒久化しかけていた期限について整理します。

すべてのナビゲーションでワーカーがしていたこと

例外なく stale-while-revalidate でした。 キャッシュをすぐ返し、ネットワークから取れた版は次回のために書き戻します。

scripts/sw/service-worker.js
const cached = await cache.match(key);
if (cached) {
event.waitUntil(revalidate(request, key, cache));
return cached;
}

この分岐を逃れる経路はありませんでした。 ワーカーを作ったときのコメントにも、そう書いてあります。「読者は1回のナビゲーションぶんだけ古いビルドを見ることになるが、ブログではその代わりに即座に表示されるほうが価値がある」

この取引は記事に対しては良いものです。記事の中身は公開後にほとんど動かないので、少し古い版を返しても読者が気づかない程度の文言差で済み、代わりに即座に描画されるページが手に入ります。記事を並べるページに対しては悪い取引です。一覧における古さは「少し古い」ではなく、読者が来た目的のものが見えないという意味だからです。 ワーカーには両者を区別する手がかりがなく、どちらも同じ経路を通っていました。

公開直後の最初の訪問を、2 つの戦略で比べる公開直後に記事一覧を開いた、同じ 1 回の訪問を 2 通り並べている。stale-while-revalidate の側はキャッシュを即座に返し、新しい版は次回の訪問のために保存するだけなので、新着記事が出てこない。network-first の側は応答する前にネットワークへ問い合わせるため、新着記事が出ている。新しい記事を公開した直後stale-while-revalidateすべてのナビゲーションの挙動キャッシュをそのまま返す古い一覧。新着が出てこない新しい版も取得するが、反映されるのは次回の訪問network-first一覧ページの現在の挙動先にネットワークへ問い合わせる最新の一覧。新着が出ている同じ公開、同じ訪問、違う答え
同じ公開、同じ訪問。キャッシュを先に返すということは、新しい版がまだ起きていない訪問のために届くということです。

再読み込みで直ったように見えた理由

キャッシュが凍っていたわけではありません。訪問のたびに更新されていて、その更新が効くのが次回だけだったのです。

ページの表示 ワーカーが裏でしたこと
新しい記事を公開する
1回目の訪問 古い一覧、新着なし 新しい一覧を取得し、キャッシュを上書き
2回目の訪問 新しい一覧、記事あり もう一度取得し、また上書き

つまり永久にキャッシュしていたのではなく、常に1回分遅れていて、前回の訪問時点の状態を見せていました。再読み込みで直ったように見えたのは、再読み込みが単に2回目の訪問だったからです。

すぐに気づけなかった理由は2つあります。通常の再読み込みは Service Worker を迂回せず、迂回するのはハードリロードだけなので、症状はキャッシュの不具合ではなくデプロイの遅れのように見えました。そして、記事がないことに気づけるのは、それが存在すると既に知っている人だけで、個人ブログではそれは1人です。

成立しない2つの修正

デプロイのたびにキャッシュを捨てる

いかにも賢そうな修正は、stale-while-revalidate は全体に残したまま、デプロイで新しいワーカーが出るたびにキャッシュ済みの一覧だけ捨てる、というものです。ワーカーは自身のソースと事前キャッシュ対象のバイト列から作った VERSION を既に持っているので、activate でその項目を削れば、次のナビゲーションはキャッシュを外してネットワークへ行くはずです。

これは成立しません。理由はこのコードベースとは関係のないところにあります。 ブラウザが /sw.js の更新を確認するのは、ナビゲーションを処理したあとであって、前ではありません。したがってデプロイ直後の最初の訪問は、新しいワーカーが activate で何をするかにかかわらず、古いワーカーが応答します。結局は同じ「2回分」の形に、キャッシュ無効化の仕組みが乗っただけです。

ワーカーの更新確認は、ナビゲーションのあとに走る4 つの手順を順に並べている。ナビゲーションが発生し、古いワーカーがキャッシュから応答する。ブラウザがワーカーのスクリプトの更新を確認し、新しいワーカーを入れてキャッシュを掃除するのは、そのあとになる。2 番目の手順の直後の区切り線が、その訪問への応答がすでに終わっている地点を示している。この訪問はもう応答済み1ナビゲーションが発生する2古いワーカーがキャッシュから応答する3ブラウザが/sw.js の更新を確認する4新しいワーカーが入り、キャッシュを掃除する新しいワーカーが activate で何をしても、1 回分だけ遅い
更新の確認は、それが直したはずのナビゲーションのあとに走ります。だからデプロイ直後の訪問は必ず前のワーカーが応答します。

一般化すると、候補となるどの修正もこの制約の下にあります。ネットワークに問い合わせない訪問が、その訪問で最新になることはありません。 ここでのネットワーク優先は好みの問題ではなく、この不具合を直せる唯一の形です。

キャッシュを返して背後で確認する

2つ目の候補は私が出したものです。キャッシュをすぐ返し、背後でネットワークに問い合わせ、返ってきたらページを更新する。実在するパターンで、エージェントは3つの根拠で反対し、どれも妥当でした。

背後での問い合わせは、ネットワーク優先が待つのと同じ往復です。 健全な回線で稼げるのは0.1秒ほどにすぎません。つながってはいるが応答しない回線では問い合わせも止まるため、読者は即座にページを得て更新は永久に来ません。元のバグが見栄えを良くしただけです。さらにブログの一覧はタップ対象が並んだ盤面で、滞在は2秒ほどです。カードに手を伸ばしている最中に一覧が描き直されれば、記事 A を押して記事 B に着地します。

このパターンが向くのは、滞在が長く、ナビゲーション対象で構成されていないページ、たとえばダッシュボードやフィードです。一覧はどちらでもありません。

一覧ページはネットワークを先に見る

修正は、取得の重さではなく URL が何のためのものかで戦略を分けます。

scripts/sw/service-worker.js
function isListing(pathname) {
const segments = pathname.split('/').filter(Boolean);
if (LOCALES.includes(segments[0])) segments.shift();
return segments.length === 0 || segments[0] === 'tags';
}

どの言語でも該当するのは2つの形です。記事一覧であるトップページと、/tags 以下のすべて。どちらも公開のたびに書き換わります。それ以外は内容であり、内容は stale-while-revalidate のままです。それこそが、このワーカーを作った目的であるオフライン閲覧を保つものです。

パスの一覧ではなく構造で判定しているので、言語が増えてもここは触りません。 最初に現れうる接頭辞は LOCALES が既に持っていますし、既定の言語は接頭辞を持たないのでトップページは単に / です。検索ページを外しているのは意図的で、その HTML に結果は入っておらず、持っているのは Pagefind のバンドルのほうで、fetch ハンドラは既にそれをネットワークに任せているからです。

これはエージェントが並べた選択肢の中から私が選びました。私が出した背後で確認する案と、ネットワークとタイマーを競わせる案を退けての採用です。

間違ったものを区切っていた期限

ネットワーク優先には本物の後退が1つあり、それは普段この取引で語られるほうの話ではありませんでした。オフラインは即座に失敗します。 電波もリクエストもないので、キャッシュがすぐ出てきます。つながってはいるが応答しない回線は、そもそも失敗しません。 トンネル、ホテルのキャプティブポータル、電波1本といった状況では、ブラウザ自身のタイムアウトまでリクエストが宙吊りになり、それは誰かが白い画面を見つめていられる時間よりはるかに長いのです。

そこで私は、フォールバックできるキャッシュがある場合に限って待ち時間を区切るよう頼みました。最初の実装は AbortSignal.timeout を使っており、出荷前にコードレビューのエージェントがその誤りを見つけました。

fetch はヘッダーが届いた時点で解決しますが、中断シグナルは本文のストリームに効いたままです。 動き続ける期限は、遅い回線ではダウンロードの途中で発火します。cache.putAbortError で失敗し、catch が古いコピーを返し、実際に届いていた新しい応答は捨てられます。回線が遅い限り、毎回です。

これはそのリリースが直そうとしていたバグより悪く、置き換えた挙動よりも悪いものです。以前の背後での再検証には期限が一切なかったので、遅い回線の読者はちょうど1回のナビゲーションだけ古く、次で新しくなっていました。

fetch の期限は、解除しないとヘッダーより長く効き続けるリクエストから 4 秒までの時間軸。AbortSignal.timeout ではヘッダー到着後も期限が動き続け、2500 ミリ秒で本文を切断するため、キャッシュへの書き込みが失敗し古いコピーが返る。ヘッダー到着で解除する AbortController では本文が最後まで届き、キャッシュが更新される。ヘッダー到着 2.0 秒期限 2500 ミリ秒本文完了 4.0 秒AbortSignal.timeout中断本文が途中で中断される。cache.put が失敗し、古いコピーが返り、届いていた応答は捨てられる。AbortController をヘッダー到着で解除タイマー解除ヘッダーが届いた時点でタイマーを解除するので、本文は必要なだけ時間をかけられる。キャッシュは更新され、新しいコピーが返る。
ヘッダー到着でタイマーを解除することが、待ち時間を区切ることとダウンロードを区切ることの分かれ目です。

修正は、ヘッダーが着いた瞬間に解除できるタイマーです。

scripts/sw/service-worker.js
const controller = new AbortController();
const deadline = fallback ? setTimeout(() => controller.abort(), NETWORK_TIMEOUT) : null;
try {
const response = await fetch(request, { signal: controller.signal });
// Headers are in, so the wait this bounds is over. Anything still to come
// is the body draining, which gets as long as it needs.
if (deadline !== null) clearTimeout(deadline);

期限を仕掛けるのは cache.match が何かを返したときだけでもあります。キャッシュが空ならフォールバック先がなく、112KB の記事を遅い回線で開こうとしている初めての読者に、成功しかけていたリクエストに対してオフラインページを返してしまうからです。

最初のテストが壊れたワーカーで通った理由

検証はヘッドレスの Chromium で本番ビルドと出荷するワーカーを動かし、訪問の合間にビルド済みの HTML を書き換えてデプロイの代わりにしています。その検証は、いま説明した壊れた実装に対して7件中7件を通しました。

一切応答しないサーバーでは、「応答なし」と「応答が遅い」を区別できません。 ヘッダーを送らないので、ヘッダーが期限内に届いて本文がそのあとも流れ続けるという場面がそもそも起きないのです。テストが見ていたのは宙吊りで、不具合が住んでいたのは遅い成功のほうでした。

これを見つける検査はヘッダーを即座に送り、本文の 72KB を4秒かけて小刻みに流し、そのうえでページが何を描いたかではなくキャッシュが何を持っているかを尋ねます

const stored = await page.evaluate(async () => {
const cache = await caches.open('oharu-pages');
const hit = await cache.match(new URL('/', location.origin).href);
return hit ? await hit.text() : '';
});

最初の実装に対して:

FAIL a slow body still lands in the cache
served stale; cache NOT updated — staleness would persist

解除できるタイマーに対して:

PASS a slow body still lands in the cache
served fresh; cache updated

検証

出荷するワーカーに対して、8件すべて通っています。

PASS listing shows a new article on the FIRST visit
PASS /tags/ shows new content on the FIRST visit
PASS article keeps stale-while-revalidate
PASS a 308 on a cached listing still redirects with the timeout attached
PASS an unslashed article URL is answered from cache, not redirected
PASS a hanging network falls back to cache within the timeout
PASS a slow body still lands in the cache
PASS home is readable offline after PRIME, without ever visiting it

意味を持つ数字は8ではありません。修正前のワーカーに同じ検証を当てた結果のほうです。失敗しえないテストは何も証明しないからです。

FAIL listing shows a new article on the FIRST visit
home served a stale copy — the reported bug
FAIL /tags/ shows new content on the FIRST visit
tag index served stale
PASS article keeps stale-while-revalidate
first visit stale: true, second visit fresh: true
FAIL home is readable offline after PRIME, without ever visiting it
offline navigation rendered the offline fallback

両方の実行で通っている1件こそが、この作業の要点です。 記事の stale-while-revalidate は変わってはならない挙動なので、修正後にだけ緑になる検査があるなら、それは修正が何かを奪ったという意味になります。

宙吊りの回線の検査は、2500ミリ秒の枠に対して2541ミリ秒でした。AbortSignal.timeout は機能検出で回避するのではなく置き換えており、それによって対応状況の問題も消えています。必要なのは Chrome 103、Firefox 100、Safari 16 以降ですが、AbortController はその3つのいずれよりも古くから使えます。

まとめ

記事 一覧
戦略 stale-while-revalidate ネットワーク優先
公開直後の最初の訪問 キャッシュを返し、背後で更新 現在の版
古いことの代償 誰も気づかない文言差 記事が見えない
オフライン キャッシュから返す キャッシュから返す
つながるが応答しない回線 キャッシュから返す 2500ミリ秒後にキャッシュ

このブログの外にも通じることが3つあります。キャッシュは取得の重さではなく URL が何のためのものかで分けること。 一覧とそこに並ぶ内容とでは、古くなったときの壊れ方が違うからです。Service Worker の更新は、それが直すはずのナビゲーションより後ろに着地すること。 そのためデプロイ時に無効化するどの方式も、1回分の遅れをそのまま引き継ぎます。そして**fetch の期限は、解除しない限りヘッダーより長く効き続けること。** 遅い回線への備えが、古い状態の保証に変わってしまいます。

最後の1つは、40行ほどの変更にコードレビューを走らせる価値があった理由でもあります。見つかった不具合は、慎重に議論した戦略の側ではなく、誰も計測していなかった場合に備えて最後に足した3行の安全網の側にありました。

参考リンク

この記事をシェア