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

目次
はじめに
記事を公開してブログを開いたのに、一覧に出てきませんでした。再読み込みすると出てきます。毎回そうなるので、サイトが自分のトップページのキャッシュを返しているのだろうと見当をつけました。
オフライン閲覧を 0.32.0 で出したのが2日前だったので、時期からして怪しいのはそれと一緒に入った Service Worker でした。調べてみると、やはりそうでした。ワーカーはすべてのナビゲーションに同じキャッシュ戦略を当てていて、記事を並べるだけの一覧ページも例外ではありませんでした。現在は、一覧ページだけ先にネットワークを見るようにしています。
本記事では、1つの戦略で両方の種類のページをまかなえない理由と、遅い回線でこのバグを恒久化しかけた fetch の期限について整理します。
すべてのナビゲーションでワーカーがしていたこと
すべてのナビゲーションが stale-while-revalidate を通っていました。キャッシュをすぐ返し、ネットワークから取れた版は次回のために書き戻します。
const cached = await cache.match(key);if (cached) { event.waitUntil(revalidate(request, key, cache)); return cached;}この分岐を逃れる経路はありませんでした。横に書かれていたコメントにも、そうあります。「読者は1回のナビゲーションぶんだけ古いビルドを見ることになるが、ブログではその代わりに即座に表示されるほうが価値がある」
このトレードオフは、記事なら割に合います。記事の中身は公開後にほとんど動きません。少し古い版を返しても、読者が気づかない程度の文言差で済み、代わりに即座に描画されるページが手に入ります。
記事を並べるページでは、払う代償が違います。一覧が古いと、読者が探しに来た記事そのものが見えなくなります。ワーカーには2種類のページを区別する手がかりがなく、どちらも同じ経路を通っていました。
再読み込みで直ったように見えた理由
キャッシュが凍っていたわけではありません。訪問のたびに更新されていて、その更新が効くのが次回だけだったのです。
| ページの表示 | ワーカーが裏でしたこと | |
|---|---|---|
| 新しい記事を公開する | ||
| 1回目の訪問 | 古い一覧、新着なし | 新しい一覧を取得し、キャッシュを上書き |
| 2回目の訪問 | 新しい一覧、記事あり | もう一度取得し、また上書き |
サイトは常に1回分遅れていて、前回の訪問時点の状態を見せていました。再読み込みで直ったように見えたのは、再読み込みが2回目の訪問だったからです。
通常の再読み込みは Service Worker を迂回しません。迂回するのはハードリロード(Shift+再読み込み)だけです。そのため、ハードリロードで最初から新しい記事が出れば、古い一覧を返しているのはサーバーではなくワーカーだと確かめられます。
しかもこの症状は、書いた本人にしか見えません。記事がないことに気づけるのは、その記事があると既に知っている人だけで、個人ブログではそれは1人です。
ネットワーク優先以外の2案:デプロイ時の無効化と、背後での確認
どちらの案でも、公開直後の最初の訪問で新しい版は出ません。
デプロイのたびにキャッシュを捨てる
1つ目の案は、stale-while-revalidate は全体に残したまま、デプロイで新しいワーカーが出るたびにキャッシュ済みの一覧だけ捨てるものです。ワーカーは自身のソースと事前キャッシュ対象のバイト列から作った VERSION を既に持っています。activate で一覧を削れば、次のナビゲーションはキャッシュを外してネットワークへ行くはずです。
ブラウザが /sw.js の更新を確認するのは、現在のワーカーがナビゲーションを処理したあとです。Service Worker の仕様では、この確認は fetch イベントが終わってから始まります。そのためデプロイ直後の最初の訪問は、新しいワーカーが activate で何をするかにかかわらず、古いワーカーが応答します。デプロイ時に無効化しても、1回分の遅れは残ります。
候補となるどの修正も、この制約の下にあります。ネットワークに問い合わせない訪問が、その訪問で最新になることはありません。この不具合を直せる形として残るのは、ネットワーク優先だけです。
キャッシュを返して背後で確認する
2つ目の候補は私が出したものです。キャッシュをすぐ返し、背後でネットワークに問い合わせ、返ってきたらページを更新する。実在するパターンですが、一覧ページでは次の理由で成り立ちません。
- 背後での問い合わせは、ネットワーク優先が待つのと同じ往復です。健全な回線では、ほとんど何も稼げません。
- つながってはいるが応答しない回線では、問い合わせも止まります。読者は即座にページを得ますが、更新は永久に来ません。最初の描画が速くなっただけで、元のバグと同じです。
- ブログの一覧はタップするカードが並ぶだけのページで、読者は数秒で次の記事へ移っていきます。カードに手を伸ばしている最中に一覧が描き直されれば、記事 A を押したつもりで記事 B を開いてしまいます。
このパターンが向くのは、長く開いたままにされ、リンクを押して移動するためのページではないもの、たとえばダッシュボードやフィードです。一覧はそのどちらにも当てはまりません。
一覧ページはネットワークを先に見る
修正では、URL が何のためのものかで戦略を分けました。
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 が解決したあとでも応答の本文をエラーにします。解除されずに残った期限は、遅い回線ではダウンロードの途中で発火します。
すると cache.put が AbortError で失敗し、catch が古いコピーを返し、実際に届いていた新しい応答は捨てられます。回線が遅い限り、これが毎回起きます。
これでは、置き換える前より悪くなっています。以前の背後での再検証には期限が一切なかったので、遅い回線の読者が古い版を見るのはちょうど1回のナビゲーションだけで、次には新しくなっていました。
修正は、ヘッダーが着いた瞬間に解除できるタイマーです。
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件を通しました。
一切応答しないサーバーでは、「応答なし」と「応答が遅い」を区別できません。ヘッダーを送らないので、ヘッダーが期限内に届いて本文がそのあとも流れ続けるという場面がそもそも起きません。テストが見ていたのは宙吊りで、不具合があったのは遅い成功のほうでした。
これを見つける検査は、ヘッダーを即座に送り、本文の 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 visitPASS /tags/ shows new content on the FIRST visitPASS article keeps stale-while-revalidatePASS a 308 on a cached listing still redirects with the timeout attachedPASS an unslashed article URL is answered from cache, not redirectedPASS a hanging network falls back to cache within the timeoutPASS a slow body still lands in the cachePASS 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 bugFAIL /tags/ shows new content on the FIRST visit tag index served stalePASS article keeps stale-while-revalidate first visit stale: true, second visit fresh: trueFAIL home is readable offline after PRIME, without ever visiting it offline navigation rendered the offline fallbackarticle 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のタイマーはヘッダー到着時に解除しないと、そのまま残り続けます。遅い回線への備えのはずが、回線の遅い読者に古いページを見せ続ける仕組みになってしまいます。


