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

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

- Source: https://oharu121.com/ja/blog/astro-service-worker-stale-index-network-first/
- Published: 2026-08-21T00:25:54+09:00
- Tags: Astro, Web API, Webパフォーマンス, Service Worker, PWA

---
**要点**

- 記事が古くても、読者が気づかない程度の文言差でしかありません。一覧が古いと、読者が来た目的の記事そのものが見えなくなります。1つの戦略で両方を扱うことはできません。
- ネットワークに問い合わせない訪問が最新になることはありません。ブラウザが新しい Service Worker を確認するのはナビゲーションを処理した*あと*なので、デプロイ時に無効化する方式はすべて成立しません。
- `AbortSignal.timeout` はヘッダー到着後も本文のストリームに効き続けます。待ち時間を区切るために足した期限が、遅い回線ではむしろ古い状態を固定してしまいました。
- 一切応答しないテストサーバーでは、「応答なし」と「応答が遅い」を区別できません。だからこそ最初のテストは、壊れたワーカーに対して7件中7件通ったのです。

## はじめに

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

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

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

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

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

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

*Figure — FirstVisitAfterPublish: 同じ公開、同じ訪問。キャッシュを先に返すということは、新しい版がまだ起きていない訪問のために届くということです。*

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

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

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

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

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

## 成立しない2つの修正

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

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

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

*Figure — UpdateAfterNavigation: 更新の確認は、それが直したはずのナビゲーションのあとに走ります。だからデプロイ直後の訪問は必ず前のワーカーが応答します。*

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

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

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

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

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

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

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

```js title="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.put` が `AbortError` で失敗し、catch が古いコピーを返し、実際に届いていた新しい応答は捨てられます。回線が遅い限り、毎回です。

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

*Figure — DeadlineVsBody: ヘッダー到着でタイマーを解除することが、待ち時間を区切ることとダウンロードを区切ることの分かれ目です。*

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

```js title="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秒かけて小刻みに流し、そのうえで**ページが何を描いたかではなくキャッシュが何を持っているかを尋ねます**。

```ts
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() : '';
});
```

最初の実装に対して:

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

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

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

## 検証

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

```text
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ではありません。**修正前**のワーカーに同じ検証を当てた結果のほうです。失敗しえないテストは何も証明しないからです。

```text
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行の安全網の側にありました。

## 参考リンク

- [MDN の AbortSignal.timeout()。シグナルがヘッダー待ちだけでなくリクエストそのものを中断することも書かれている](https://developer.mozilla.org/ja/docs/Web/API/AbortSignal/timeout_static)
- [Can I use: AbortSignal.timeout() の対応状況。Chrome 103 / Firefox 100 / Safari 16](https://caniuse.com/mdn-api_abortsignal_timeout_static)
- [MDN の Request コンストラクタ。空でない init を渡すと mode navigate が same-origin に落ちる規則を含む](https://developer.mozilla.org/ja/docs/Web/API/Request/Request)
- [MDN の Service Worker のライフサイクル。ブラウザがワーカーのスクリプトの更新を確認する時点を含む](https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API/Using_Service_Workers)
