Astro の Service Worker が新着記事を1回分遅れて表示していた — 一覧ページをネットワーク優先にする

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

更新

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

はじめに

記事を公開してブログを開いたのに、一覧に出てきませんでした。再読み込みすると出てきます。毎回そうなるので、サイトが自分のトップページのキャッシュを返しているのだろうと見当をつけました。

オフライン閲覧を 0.32.0 で出したのが2日前だったので、時期からして怪しいのはそれと一緒に入った Service Worker でした。調べてみると、やはりそうでした。ワーカーはすべてのナビゲーションに同じキャッシュ戦略を当てていて、記事を並べるだけの一覧ページも例外ではありませんでした。現在は、一覧ページだけ先にネットワークを見るようにしています。

本記事では、1つの戦略で両方の種類のページをまかなえない理由と、遅い回線でこのバグを恒久化しかけた fetch の期限について整理します。

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

すべてのナビゲーションが 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種類のページを区別する手がかりがなく、どちらも同じ経路を通っていました。

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

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

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

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

サイトは常に1回分遅れていて、前回の訪問時点の状態を見せていました。再読み込みで直ったように見えたのは、再読み込みが2回目の訪問だったからです。

通常の再読み込みは Service Worker を迂回しません。迂回するのはハードリロード(Shift+再読み込み)だけです。そのため、ハードリロードで最初から新しい記事が出れば、古い一覧を返しているのはサーバーではなくワーカーだと確かめられます。

しかもこの症状は、書いた本人にしか見えません。記事がないことに気づけるのは、その記事があると既に知っている人だけで、個人ブログではそれは1人です。

ネットワーク優先以外の2案:デプロイ時の無効化と、背後での確認

どちらの案でも、公開直後の最初の訪問で新しい版は出ません。

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

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

ブラウザが /sw.js の更新を確認するのは、現在のワーカーがナビゲーションを処理したあとです。Service Worker の仕様では、この確認は fetch イベントが終わってから始まります。そのためデプロイ直後の最初の訪問は、新しいワーカーが activate で何をするかにかかわらず、古いワーカーが応答します。デプロイ時に無効化しても、1回分の遅れは残ります。

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

候補となるどの修正も、この制約の下にあります。ネットワークに問い合わせない訪問が、その訪問で最新になることはありません。この不具合を直せる形として残るのは、ネットワーク優先だけです。

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

2つ目の候補は私が出したものです。キャッシュをすぐ返し、背後でネットワークに問い合わせ、返ってきたらページを更新する。実在するパターンですが、一覧ページでは次の理由で成り立ちません。

  1. 背後での問い合わせは、ネットワーク優先が待つのと同じ往復です。健全な回線では、ほとんど何も稼げません。
  2. つながってはいるが応答しない回線では、問い合わせも止まります。読者は即座にページを得ますが、更新は永久に来ません。最初の描画が速くなっただけで、元のバグと同じです。
  3. ブログの一覧はタップするカードが並ぶだけのページで、読者は数秒で次の記事へ移っていきます。カードに手を伸ばしている最中に一覧が描き直されれば、記事 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] === "posts" || segments[0] === "tags";
}

どの言語でも該当するのは3つの形です。新着記事を並べるトップページ、記事アーカイブとそのページ送りである /posts 以下、そして /tags 以下です。この3つは公開のたびに書き換わります。それ以外は記事などの中身を読ませるページなので、stale-while-revalidate のままです。これによって、このワーカーの目的だったオフライン閲覧が保たれます。

パスの一覧ではなく構造で判定しているので、言語が増えてもここは触りません。最初に現れうる接頭辞は LOCALES が既に持っていますし、既定の言語は接頭辞を持たないので、トップページは単に / です。

検索ページは意図的に外しています。その HTML に結果は入っておらず、結果を持っているのは Pagefind のバンドルです。fetch ハンドラは、そのバンドルを既にネットワークに任せています。

待ち時間の上限と、AbortSignal.timeout が本文を中断した理由

ネットワーク優先にしたことで、1つだけ本当に悪くなった点がありました。オフラインは即座に失敗します。電波もリクエストもないので、キャッシュがすぐ出てきます。つながってはいるが応答しない回線は、そもそも失敗しません。トンネル、ホテルのキャプティブポータル、電波1本といった状況では、ブラウザ自身のタイムアウトまでリクエストが宙吊りになります。

そこで私は、フォールバックできるキャッシュがある場合に限って待ち時間を区切るよう頼みました。最初の実装は AbortSignal.timeout を使っていました。

fetch はヘッダーが届いた時点で解決しますが、中断シグナルは本文のストリームに効いたままです。Fetch の仕様の中断手順は、fetch の Promise が解決したあとでも応答の本文をエラーにします。解除されずに残った期限は、遅い回線ではダウンロードの途中で発火します。

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

すると cache.put が AbortError で失敗し、catch が古いコピーを返し、実際に届いていた新しい応答は捨てられます。回線が遅い限り、これが毎回起きます。

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

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

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 });
// ヘッダーは届いたので、この期限が区切る待ち時間は終わり。ここから先は
// 本文が流れ切るのを待つだけで、必要なだけ時間をかけてよい。
if (deadline !== null) clearTimeout(deadline);

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

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

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

テストの流れと、応答しないサーバーでは 2 つの実装を見分けられない理由上段は 4 つの手順。サイトとワーカーをビルドし、1 回目の訪問でキャッシュを作り、ビルド済みの HTML を書き換えて公開を再現し、2 回目の訪問でキャッシュの中身を確認する。下段は同じ 0 秒から 4 秒の時間軸を持つ 2 枚のパネルで、2500 ミリ秒に期限がある。左は一切応答しないサーバーで、期限が来た時点で両方の実装がまだヘッダーを待っており、同じ時点で中断してキャッシュに切り替わるので結果は同じ。右はヘッダーを即座に送り本文を 4 秒まで流すサーバーで、ヘッダー到着後に AbortSignal.timeout は期限で本文を切り、解除できるタイマーは本文を 4 秒まで受け取るので結果が分かれる。テストの流れ1サイトとワーカーをビルドする21 回目の訪問でキャッシュを作る3ビルド済み HTML を書き換えて公開を再現42 回目の訪問でキャッシュの中身を確認2 つの実装が分かれる場所一切応答しないサーバーヘッダーが一度も届かない0 秒4 秒期限 2500 ミリ秒ゆっくり応答するサーバーヘッダーは即座に、本文は 4 秒まで0 秒4 秒期限 2500 ミリ秒どちらもヘッダー待ちのままAbortSignal.timeoutキャッシュへ切り替え解除できるタイマーキャッシュへ切り替え結果は同じヘッダー到着後AbortSignal.timeout本文が切れる解除できるタイマー結果が分かれる2 つの実装の違いはヘッダー到着後にしか現れず、応答しないサーバーではそこまで進まない
一切応答しないサーバーでは、両方の実装が通ります。ヘッダーを送ってから本文を小刻みに流すサーバーだけが、ヘッダー到着後も期限が解除されずに残る場面を再現できます。

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

これを見つける検査は、ヘッダーを即座に送り、本文の 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() : "";
});

AbortSignal.timeout に対して:

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

失敗しえないテストは何も証明しません。そこで同じ検証を修正前のワーカーにも当てました。

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

article keeps stale-while-revalidate は両方の実行で通っており、そうでなければなりません。記事の stale-while-revalidate は変わってはならない挙動です。修正後にだけ緑になる検査があるなら、修正が何かを奪ったことになります。

宙吊りの回線の検査は、2500ミリ秒の枠に対して2541ミリ秒でした。AbortSignal.timeout を置き換えたことで、対応状況の問題も消えています。AbortSignal.timeout が入ったのは Chrome 103、Firefox 100、Safari 16 で、Chrome が仕様どおり TimeoutError を投げるのは 124 からです。一方、AbortController はその3つのいずれよりも古くから使えます。

まとめ

記事一覧
戦略stale-while-revalidateネットワーク優先
公開直後の最初の訪問キャッシュを返し、背後で更新現在の版
古いことの代償誰も気づかない文言差記事が見えない
オフラインキャッシュから返すキャッシュから返す
つながるが応答しない回線キャッシュから返す2500ミリ秒後にキャッシュ
  • キャッシュは URL が何のためのものかで分けます。一覧とそこに並ぶ内容とでは、古くなったときの壊れ方が違います。
  • Service Worker の更新確認は、それが直すはずのナビゲーションのあとに始まります。そのため、デプロイ時に無効化するどの方式も1回分の遅れを引き継ぎます。
  • fetch のタイマーはヘッダー到着時に解除しないと、そのまま残り続けます。遅い回線への備えのはずが、回線の遅い読者に古いページを見せ続ける仕組みになってしまいます。

参考リンク

この記事をシェア