# Astroブログで読んだ記事をオフラインでも開けるようにする — Service Worker、ページ50件の上限、TTLなし

> 計測がAstroブログのService Workerの設計を決めました。11.7MBのHTMLではなく287KBを事前キャッシュし、ページは50件で上限、有効期限は設けません。

- Source: https://oharu121.com/ja/blog/astro-service-worker-offline-reading-visited-pages-cap/
- Published: 2026-08-20T09:05:14+09:00
- Tags: Astro, Web API, Webパフォーマンス, Service Worker, PWA, iOS

---
**要点**

- `localStorage` ではページをオフラインで配信できません。ネットワーク層から到達できないため、ナビゲーションのリクエストが参照することはありません。
- ビルドを計測した時点で、順当に思えた設計は消えました。42MBの出力のうち11.7MBがHTMLで、サイト全体の事前キャッシュは最初から成り立ちません。シェルは287KB、残りは読んだ分だけ保持します。
- 有効期限は設けません。デプロイが変わった分だけを正確に無効化するため、増加は件数の上限で抑えます。ページ50件、画像200件です。
- DevTools の「Offline」でのテストは、Service Worker について何も証明しません。制限がかかるのはページのターゲットだけで、ワーカーは自分のネットワークを持ったまま、稼働中のサーバーから取得して応答します。

## はじめに

私はこのブログを読みます。書いている本人が言うのも妙ですが、調べ物のために自分の記事へ戻ることがあり、それはたいてい接続のない飛行機の中で、何も入っていないタブを前にしたときです。やりたかったことは単純でした。一度読んだ記事を、Wi-Fiを切ったまま開いて、そこにあってほしい。

それが可能なのかどうか、私は知りませんでした。最初に思いついたのは本文を `localStorage` に入れておいて後から読み出す方法で、これは推測で済ませず確認してもらいました。**答えは「できない」で、理由は構造的なものです。`localStorage` はネットワーク層から到達できないため、ナビゲーションのリクエストがそれを参照することはありません。** オフラインのナビゲーションに応答できるのは、Cache API にレスポンスを保持した Service Worker だけです。

残りを決めたのは好みではなく、ビルドの計測でした。本記事では、順当に思えた設計を退けた数値、そこから出てきた4つのキャッシュ、そして途中で自信たっぷりに間違っていた4つのチェックを整理します。

## localStorage はナビゲーションに応答できない

`localStorage` の魅力は、書き込みも読み出しも1行で済むことです。問題は、ブラウザがURLを取得しにいくときに何が起きるかにあります。参照されるのはHTTPキャッシュとネットワークだけで、それ以外はありません。ページが自分で保存した文字列が応答の候補として差し出される場所は、どこにもないのです。

さらに3つの制約があります。上限はおよそ5MBで、ここの記事1本でほぼ埋まります。保存できるのは文字列なので、HTML文書はヘッダーもステータスもContent-Typeも持たない1本の長い文字列として入ります。そして同期的であるため、大きなエントリの読み出しはメインスレッドを止めます。

**Cache API はこの4点すべての逆です。** `Request` と `Response` の組をヘッダーとステータスごと保存します。これはまさに、ナビゲーションに応答するために必要なものです。Service Worker はネットワークの手前にプロキシとして立ち、タブを閉じたあとも生き続け、保存したレスポンスを返せます。

```js
// A service worker can answer a navigation. Nothing else in the browser can.
self.addEventListener('fetch', (event) => {
	if (event.request.mode === 'navigate') {
		event.respondWith(handleNavigation(event));
	}
});
```

## ビルドを計測すると何が見えるか

この時点での直感は「サイトを事前キャッシュして終わりにする」です。それを止めたのが `.vercel/output/static/` の計測でした。

| 内訳 | サイズ | ファイル数 |
| --- | --- | --- |
| 記事のHTML（公開73ページ、3言語） | 8.87 MB | 73 |
| タグ・一覧・検索・プライバシーのHTML | 2.81 MB | 109 |
| `_astro/` の画像 | 22.81 MB | 489 |
| `pagefind/` | 2.20 MB | 約100 |
| Markdown の複製 | 1.42 MB | 73 |
| **CSS・JS・Webフォント** | **0.21 MB** | 19 |
| フィード・サイトマップ・`llms.txt`・アイコン | 3.68 MB | 約20 |
| **合計** | **42 MB** | 883 |

**記事1ページのHTMLは最大で300KBに達します。** これは無駄ではありません。このサイトの図版はラベルを翻訳できるようインラインSVGのコンポーネントになっているため、図が別ファイルではなく文書の中に入ります。3言語を扱う以上それが正しい判断であり、同時に「HTMLだけ事前キャッシュすればいい」が11.7MBかかる理由でもあります。

1ページを求めただけの端末に11.7MBを送り込むのは、ほとんどの読者が使わない保証と引き換えにするには割に合いません。**そこで設計は反転しました。シェルを事前キャッシュし、通りがかったページはそのつど保持します。** オフラインとは「すでに開いたものはすべてある」という意味になり、これは実際に起きる場面そのものです。しかも追加のバイトはかかりません。そのレスポンスはページを描くためにどのみち取得されていたからです。

シェルは27エントリで287KBに収まりました。スタイルシート14本、スクリプト4本、Webフォント、アイコン、そして3つのウェブアプリマニフェストです。この数字は、以前に私が投げていた「サイトが黙って何かをダウンロードすることを読者は嫌がらないか」という問いにも決着をつけました。11.7MBなら答えは「嫌がる」です。287KBなら、同意ダイアログは守ろうとしているものより多くの注意を奪います。したがってダイアログは出さず、譲るべき箇所は別にしました。`navigator.connection.saveData` です。データセーバーを有効にしている読者にもワーカーは入り、読んだものは保持されます。ただし先回りの取得だけは発生しません。

**この譲歩は聞こえるほど大きくないので、普遍的なものとして読まれるより、そう書いておくほうが誠実です。** Network Information API は Baseline ではなく、MDN は「広く使われているブラウザの一部で動作しない」という理由で*限定的な利用可能性*と記しています。`navigator.connection` が存在しない環境ではオプショナルチェーンが `undefined` を返し、ガードは何もせず、シェルは変わらず取得されます。この譲歩は実装しているブラウザでは本物で、それ以外では効きません。

## 4つのキャッシュと、1つだけ削除しない理由

*Figure — CacheLayout: シェルは削除ではなく上限で抑えます。デプロイ前にキャッシュされたHTMLが参照するスタイルシートを、そのまま見つけられるようにするためです。*

増え方については、私から3つ質問しました。読者が実際にいる言語だけをキャッシュすべきか、エントリに期限を設けるべきか、上限を設けるべきか。答えは、はい・いいえ・はい、でした。

**事前キャッシュされるのは訪問中の言語のホームページだけです。** スタイルシートとスクリプトは3言語で共有されるため、言語ごとに異なるのはホームページとオフラインページだけです。読者が開くことのない言語を先に取得するのは、この設計全体が避けている先回りそのものです。

**有効期限は設けません。Cache API に期限の仕組みがなく、それを自作するのは持たないより悪いからです。** TTLを持たせるということは、陳腐化を推測するためだけにタイムスタンプの表を別に維持することを意味します。一方でビルドは、変わった分を正確に無効化しています。デプロイのたびにワーカーのバージョンが変わり、シェルはそれに合わせて突き合わせられます。さらにナビゲーションのたびに背景で再検証が走ります。この最後の点は記事には当てはまりましたが、記事を一覧するページには当てはまらず、後の記事で訂正しています。**[一覧ページはネットワークを先に見る必要がありました](/ja/blog/astro-service-worker-stale-index-network-first/)**。時計を持ち込めば、読者が意図して残したページを、その内容が変わったかどうかとは無関係な周期で捨てることになります。

適した手段は上限のほうで、計測した平均値から決めています。記事のHTMLは121KB、`_astro` の画像は48KBです。

| キャッシュ | 保持するもの | 上限 | 上限時のサイズ |
| --- | --- | --- | --- |
| `oharu-pages` | 読んだ記事のHTML | 50 | 約6 MB |
| `oharu-images` | ページが描画した図版 | 200 | 約9.6 MB |
| `oharu-shell` | CSS・JS・フォント・アイコン・マニフェスト | 120 | 約1 MB |
| `oharu-offline` | オフライン用ページ3枚 | 削除しない | 約60 KB |

**破棄は挿入順のFIFOです。Cache API が露出している順序はこれだけだからです。** 本当のLRUにはアクセス順を記録する IndexedDB の副テーブルが要りますが、時間軸におおむね沿って読まれるブログでは両者を区別できません。そのためにデータベースを1つ持ち込む価値はありません。

立ち止まる価値があるのは最後の行です。**オフラインページが専用のキャッシュにあるのは、それらが最初に書き込まれるからで、FIFOの削除は先頭から始まります。** シェルに置いたままなら真っ先に破棄される側になり、サイトを使い込むほどフォールバックのページが消えていくことになります。

## 稼働中のサーバー相手に通ってしまったオフラインテスト

最初の検証はもっともらしく見えて、何の役にも立ちませんでした。

エージェントは Chrome DevTools を `Network: Offline` にし、キャッシュ済みの記事へ移動して、表示されるのを確認しました。続いて一度も訪れていない記事へ移動すると、それも表示されました。読んだ分だけ保持する設計でできるはずのないことです。決め手になったのは、ページ内で実行した1行でした。

```js
try { await fetch('/robots.txt', { cache: 'no-store' }); }
catch (e) { console.log('THREW: ' + e.message); }   // THREW: Failed to fetch
```

**ページには本当にネットワークがありませんでした。ワーカーにはありました。**

*Figure — TargetSplit: `Network.emulateNetworkConditions` はターゲット単位で適用され、ワーカーは自分が配信するページとは別のターゲットです。*

**Service Worker は独立した DevTools のターゲットであり、`Network.emulateNetworkConditions` は設定したターゲットにだけ効きます。** ページはオフラインで、ワーカーはそうではなく、ワーカーは各ページをその場で取得して返していました。すべてのテストが誤った理由で通っていたわけです。このエミュレーションでは `navigator.onLine` も `true` のままで、気づく手がかりになり得たもう一方の信号も消えます。

修正は、エミュレーションをやめてサーバーごと止めることです。

```bash
lsof -ti:4321 | xargs kill
```

本当に死んだオリジンに対して測り直せば、結果に意味が出ます。訪問済みの記事は、スタイルシートと実際に取得済みだった画像2点を伴い、63段落が完全に表示されました。未訪問のURLは正しい言語のオフラインページを返し、ステータスは503、要求したURLはアドレスバーに残ったままでした。

## 決して成立しないリダイレクト判定

コードレビューがクラッシュを見つけました。探しにいって初めて出てくる類のものです。

*Figure — RedirectPath: opaqueredirect では `redirected` が false なので、それを見ていた判定は一度も動かず、レスポンスは status 0 を拒否するコンストラクタへ落ちていきました。*

**ナビゲーションの `Request` はリダイレクトモード `manual` を持ちます。** つまり3xxは「たどられたリダイレクト」としては届きません。届くのは*opaqueredirect*で、`type` は `'opaqueredirect'`、`status` は `0`、`ok` は `false`、そして `redirected` も `false` です。元のコードは、まさに決して true にならないフィールドを見ていました。

```js
// Never true for a navigation. The response falls straight through.
if (response.redirected) return Response.redirect(response.url, 302);

if (response.ok) { /* … */ }

return offlineResponse(url, response.status);   // status === 0
```

その先で `offlineResponse` は `new Response(body, { status: 0 })` に到達しますが、`Response` のコンストラクタは200から599しか受け付けません。**`RangeError` が投げられ、その reject が `respondWith` の外へ抜け、ワーカーさえ入っていなければ問題なく開けたURLに対して、読者はブラウザ自身のエラー画面を受け取ります。** 301を返すローカルサーバーで再現したときは `net::ERR_FAILED` で、修正後は同じURLが開きます。

```js
if (response.type === 'opaqueredirect' || response.status === 0) return response;
```

**ここで1つ訂正します。緊急性について、私は誤った説明を受けていました。** そのとき示された理由は、Vercelは末尾のスラッシュがないディレクトリURLに308を返すので、スラッシュの落ちたリンクはすべて壊れる、というものでした。あとから本番を調べると、リダイレクトは起きていません。`/ja`、`/privacy`、`/ja/tags`、さらに `/…/index.html` まで、すべて200です。クラッシュは実在し再現もしましたが、その根拠として挙げられた引き金は現在のルーティングでは発火しません。この修正が実際に買っているのは、今日の通信に潜むバグの解消ではなく、将来リダイレクト規則が追加されたときの安全であり、この2つは同じ主張ではありません。

## PNGのバイト列は環境をまたいで一致しない

ビルドはホーム画面用のアイコンを `public/favicon.svg` から生成しており、生成元との食い違いを捕まえるために `--check` モードを書きました。生成したPNGのバイト列を、コミット済みのものと比較する作りです。手元では通り、CIでは落ちました。

```text
PWA icons are stale:
  public/pwa-icon-512-maskable.png — differs from 20575 bytes
  public/apple-touch-icon.png — differs from 6395 bytes
```

手元でのこの2つは20596バイトと6399バイトです。**差が出た2つは、`sharp.composite()` を通る2つとちょうど一致します。** 素のラスタライズだけで作られる残り2つはバイト単位で一致しました。libvips は macOS の arm64 と CI の Linux x64 とで合成もエンコードも異なるため、このチェックは何の問題もないアートワークを報告しながら、CIで永久に落ち続けたはずです。

このチェックが本来捕まえたいのは、favicon を編集して再生成を忘れる事態です。それは入力の変化なので、いまは入力どうしを比べます。SVGのハッシュ、アイコン仕様のハッシュ、そして各ファイルから実際に読み直した寸法です。

```json title="scripts/pwa-icons.lock.json"
{
	"source": "public/favicon.svg",
	"sourceHash": "64655d5b744ddbff",
	"specHash": "ebf2f5ba3712cce2"
}
```

寸法はロックの記載を信じずファイルから読むので、切り詰められたPNGや手で置き換えられたPNGも捕まります。そしてこのチェック自体、わざと壊して確かめました。`sourceHash` を書き換えれば1で終了し、再生成すれば0で終了します。**通るところしか見たことのないチェックは、まだテストされていません。**

## すでに完成していたコードに残っていたTODO

ほかの3つは、何も問題がないときに「問題なし」と言うチェックでした。これは逆向きです。

`offlineLocaleFor` はどの言語のオフラインページを返すかを決める関数で、`TODO` と、接頭辞のないURLの扱いを編集上の未決事項として説明する20行の注記を抱えたまま出ていました。未決ではありませんでした。`prefixDefaultLocale: false` により英語のページは `src/pages/` の直下にあるので、`/blog/some-slug/` は言語が曖昧なアドレスではありません。英語のアドレスであり、この関数はサイトが生成しうるすべてのURLに対してすでに正しい答えを返していました。

```js
function offlineLocaleFor(url) {
	const prefix = url.pathname.split('/')[1];
	return LOCALES.includes(prefix) ? prefix : DEFAULT_LOCALE;
}
```

**実際に未決だったのはもっと小さな選好であり、欠陥ではありません。** `navigator.language` を読んで、URLの言語を読者の言語で上書きするかどうかです。これは見送りました。ブラウザを覗くワーカーは、このサイトで唯一言語を推測する部品になってしまいますし、`ja-JP` を持ちながら意図して英語を読んでいる読者が少なくないここでは、英語のリンクに日本語の通知を返すことになるからです。マーカーは外し、その理由はドキュメンテーションコメントへ移しました。そこでなら、単に「無い」と気づかれるのではなく、議論の対象になれます。

## 何が出て、何がまだ確かめられていないか

ワーカーは稼働しています。記事を読めばキャッシュされ、`/api/likes/*` と `/pagefind/*` はどのキャッシュにも入らず、シェルはアセット27点で落ち着きます。読者自身のホームページはシェルではなく、読んだページと同じ側に保存され、ほかの2言語からは何も入りません。

そのうえで、端末を機内モードにしてサイトを開きました。

*Image: 機内モードのiPhoneに表示されたオフラインページ。「You are offline」の見出しと、Try again・Homeのボタン、その上にサイトのヘッダーが並んでいる*

*一度もキャッシュされていないURLです。文言は「見つかりません」ではなくオフラインのほうで、これは DevTools のテストでは到達できなかった分岐です。*

このスクリーンショットは、デスクトップでの検証では確かめられなかったことに決着をつけます。ページは `navigator.onLine` を見て2つの文言を選び分けますが、DevTools のエミュレーションではこの値が `true` のままなので、どれだけ通信を絞ってもオフラインの文言は現れませんでした。**実際に電波を切ることだけがこの分岐を動かし、そして正しいほうが選ばれました。**

*Image: 同じiPhone、機内モードのまま。以前に読んだ記事がサムネイル画像とタイトルごと完全に表示されている*

*先に読んでおいた記事が、電波なしでキャッシュから返っています。サムネイルが一緒に出ているのは、そのページがすでに取得済みだったからです。*

**これが、このために作ったものが、このために想定していた端末で動いている状態です。** ヘッダーも言語切り替えもフッターも揃っているのでシェルは届いており、記事と画像は2つのランタイムキャッシュから出ています。

計画時から答えの出ないまま持ち越していた問いが1つあり、本番へのデプロイがそれに決着をつけました。Vercel のアダプターは自前で `.vercel/output/config.json` を書き、CIは `--prebuilt` でデプロイするため、`vercel.json` の `headers` が生き残るのかどうかは本当に不明でした。

```text
$ curl -sSI https://oharu121.com/sw.js | grep -i cache-control
cache-control: public, max-age=0, must-revalidate
```

**このヘッダーは `vercel.json` 以外のどこにも存在しないので、マージされています。** 後処理の工程は結局まったく不要でした。

先の2枚はアドレスバーとタブ数が見えている Safari の画面なので、それ自体が示すのはブラウザのタブでオフライン閲覧ができることだけで、インストール済みのアプリについては何も言いません。そこで次に、サイトをホーム画面へ入れました。

*Image: iPhoneのホーム画面に入ったアイコン。白い桜の字が入った朱色の角丸正方形で、角は透明ではなく塗られている*

*iOSはアイコンを自分の角丸正方形に切り抜きます。角が黒ではなく朱色なのは、このPNGを不透明で生成しているからです。*

**マニフェストが求めたものは、すべてそのとおりに扱われました。** アイコンはページのスクリーンショットではなく印そのもので、iOSの角丸マスクはどの角でも朱色に接します。透明なPNGなら生じていた黒い楔はありません。「ホーム画面に追加」のシートが提示した名前は `oharu` で、これはマニフェストの `short_name` です。起動するとアドレスバーもタブバーもない状態で開くので、`display: standalone` が読まれています。そして英語のルートからインストールすれば英語のホームページが開きます。`start_url` が働いている証拠です。

**未決のまま残っているのは、1週間かかるほうです。** Safari はサイトが保存したものを7日間の未使用で消去し、ホーム画面のアプリがその例外だと文書化されています。そこで `navigator.storage.persist()` が実際に許可されるのか、8日間開かなかったキャッシュが残るのかは、どちらも未検証です。`saveData` の分岐も同じで、こちらは誠実にエミュレートする方法のない1つのガードです。

## まとめ

ここでの設計は選ばれたものではありません。42MBのビルドを計測し、そのうち11.7MBがHTMLだと分かったあとに残ったものです。翻訳できる図版が文書の中にあるからそうなります。サイトの事前キャッシュは最初から成り立たず、だからシェルは287KBで、ページは読んだ分だけ保持されます。

うまくいかなかった4つのうち3つは同じ形をしていました。**チェックは存在し、実行され、何も証明しませんでした。** オフラインテストが通ったのは、制限がワーカーまで届かなかったからです。リダイレクトの判定は、対象のレスポンスでは常にfalseになるフィールドを読んでいました。アイコンのチェックは、2台の機械が決して一致させないバイト列を比べていました。どれも検証に見えて検証ではなく、これはチェックが無いことより高くつきます。誤った安心まで一緒に買ってしまうからです。

4つめは逆でした。**サイトが生成しうるすべてのURLをすでに処理していた関数に `TODO` が付いたまま出て、完成していたコードが未完成として扱われていました。** それは外され、代わりに入ったものが、この規則がこうである理由になっています。

やりたかったことは動きます。端末を機内モードにして、先に読んでおいた記事を開いたら、そこにありました。ホーム画面へのインストールも、アイコンも名前もスタンドアロン起動も含めて動きます。まだ確かめられないのは1週間かかるほうのテストで、インストールしたものをしばらく放置し、iOSが保存内容を保ち続けるかどうかを見るというものです。

## 参考リンク

- [Using Service Workers, including the fetch event and the Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers)
- [Response() constructor, whose status must be in the range 200 to 599](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response)
- [Response.type, listing opaqueredirect and what it means for a navigation](https://developer.mozilla.org/en-US/docs/Web/API/Response/type)
- [WorkerNavigator, the reason a service worker can read navigator.language](https://developer.mozilla.org/en-US/docs/Web/API/WorkerNavigator)
- [WebKit: Updates to Storage Policy, the seven-day eviction and the Home Screen exception](https://webkit.org/blog/14403/updates-to-storage-policy/)
- [StorageManager.persist(), which prompts in some browsers](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist)
- [NetworkInformation.saveData, marked limited availability rather than Baseline](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/saveData)
