スマホ 1 台を回して遊ぶパーティーゲームのサイトを作っています。このジャンルには Web アプリとして特殊な事情がひとつあって、遊ばれる現場がだいたい圏外です。居酒屋の地下、走行中のバス、山の合宿所。「URL を開くだけで遊べる」が売りなのに、いちばん遊んでほしい場所で URL が開けません。

そこで、一度読み込んだゲームは電波が切れても最後まで遊べるように、Service Worker——ブラウザとサーバーの間に入って、ページの通信を横取り・代行できる仕組み——を入れました。Workbox のようなライブラリは使わず、1 ファイルを手で書いています。手で書くと、キャッシュに何を入れるか、版をどう切り替えるか、古いロジックをいつまで配ってしまうかを、ライブラリの既定に頼らず自分で決めることになります。

振り返って言えるのは、オフライン対応が壊れないのは機能を足したからではなく、介入しすぎ・版のズレ・古いゲームロジックの配布という壊れ方を先に列挙して境界を固定したからだ、ということです。Service Worker を手で書いている、またはこれから書く個人開発者に向けて、僕が固定した境界と、それでも公開後に見つかった穴の話を書きます。

何を作ったか

mawashite.com という無料のパーティーゲームサイトです。Astro の静的ビルドで、ゲーム画面だけがブラウザ側で動く island として読み込まれます。オフライン対応の実体は 2 つ——fetch に介入する Service Worker 本体と、ビルド成果物を実際に読んで事前キャッシュ一覧を生成するビルド後フックです。一覧を手で書かないのは意志ではなく必然で、アセットのファイル名にはハッシュが付くので、手書きの一覧は次のビルドで必ず腐ります。

生成された事前キャッシュは、2026-08-09 時点で 38 ファイル・実測 719 KB (ゲーム 13 本の HTML と、ハッシュ付き JS/CSS ぜんぶ込み)。数字はゲームを足すたびに少しずつ育ちますが、それでも 1 MB を切っています。

先に「Workbox でいいのでは」に答えておくと、使わなかったのは反骨ではなく算数です。このサイトのルールは 3 つしかありません——絞った事前キャッシュ・中身由来の版・HTML だけ network-first。3 ルールのために依存とビルド設定を 1 系統増やすより、コメント込み 100 行強を自分の資産として持つほうが安い、という釣り合いの判断で、ルールが 10 個あったら素直にライブラリを使っていたと思います。

事前キャッシュは「何を入れないか」から決めた

オフラインで要るのは「ゲーム画面を最後まで動かせる分」だけです。事前キャッシュの選別はこれだけしかありません。

function shouldPrecache(file: string): boolean {
  // ハッシュ付きアセット (JS / CSS)。内容が変わればファイル名も変わるので永続キャッシュしてよい
  if (file.startsWith('_astro/')) return true;
  // ゲーム画面本体
  if (file.startsWith('play/') && file.endsWith('/index.html')) return true;
  return false;
}

トップページも紹介ページも OGP 画像も入れません。入れるほど初回訪問の通信量が増えるからです。お題のデータを別 JSON にしなかったのも同じ理由で、デッキとゲーム定義はビルド時に /play/[slug] の HTML へ props として埋め込んであるため、HTML をキャッシュすればお題も一緒に手に入ります。取りに行くファイルが減るほど、オフラインは壊れにくい。

そして選別と同じくらい大事にしたのが、事前キャッシュしていないものには一切介入しないことです。fetch ハンドラは、一覧にないリクエストと別オリジンへのリクエスト (解析タグや広告の通信) をどちらも素通しします。紹介ページも含めて、SW が無いときと完全に同じ挙動——オフライン対応のつもりで書いた 1 ファイルが、サイト全体の配信の単一障害点になるのがいちばん怖いので、介入範囲を最初に閉じました。

版はビルド日時ではなく「キャッシュの中身」から導く

キャッシュには「版」——いま端末に保存されている一式が、どの時点のビルドかを示す名前——が要ります。よくあるのはビルド日時やコミットハッシュですが、これだと中身が同じでも版が変わり、デプロイのたびに全端末がキャッシュを捨てて取り直すことになります。逆に版を固定にすると今度は更新が配れない。答えは中身そのものから導くことでした。

const urls = files.filter(shouldPrecache).map(toUrl).sort();
const version = createHash('sha256').update(urls.join('\n')).digest('hex').slice(0, 12);

URL 一覧のハッシュが版です。アセットはファイル名にハッシュを含むので、どれか 1 バイトでも変われば一覧が変わり、版が変わる。変わらなければ版も据え置きで、既存のキャッシュがそのまま生きます。

ただし、この保証が効くのはファイル名にハッシュが付くアセットまでです。/play/ の HTML は URL が固定のまま中身が変わりうるので、HTML だけが変わる更新 (お題データの修正など) は版に現れません。ナビゲーションが network-first なので電波があれば最新の HTML が届きますが、オフラインのフォールバック用に保存されたコピーは、次にアセットが変わる更新まで古いまま残ります。

この方式にはひとつ罠があって、ファイル名にハッシュの付かないファイルを事前キャッシュに入れると壊れます。favicon のような public 直下のファイルで順を追うと、①中身を差し替えてもファイル名は変わらない。②ファイル名が変わらないので URL 一覧も変わらない。③一覧が変わらないので版も変わらない。④版が変わらないので、SW は更新に永遠に気づかず古い favicon を配り続けます。だから shouldPrecache は public のファイルを最初から対象外にしています。「favicon くらい入れておくか」をやると静かに壊れる設計なので、境界をコードの側で固定しました。

なお後始末も版設計の一部で、版が変わった次の訪問では activate のタイミングで古い版のキャッシュをまとめて削除します。デプロイを重ねても、端末のストレージが古い版で肥大することはありません。

いちばん守ったのは「古いゲームロジックを配らない」

オフライン対応で本当に怖いのは、オフラインで動かないことではなく、ルールを直したのに古いゲームが配られ続けることです。ここは 3 つの決めで守っています。

if (request.mode === 'navigate') {
  // ゲーム画面の HTML だけは毎回ネットワークを先に試す
  event.respondWith(
    fetch(request).catch(async () => {
      const cached = await caches.match(path);
      return cached ?? Response.error();
    }),
  );
  return;
}
// ハッシュ付きアセットはキャッシュ優先で問題ない
event.respondWith(caches.match(path).then((cached) => cached ?? fetch(request)));

ひとつ、ナビゲーションは network-first。電波があるかぎり HTML は常に最新で、キャッシュはフォールバック専用です (見ているのは「ネットワークに到達できたか」だけで、サーバーが 5xx を返せばそれがそのまま表示されます——キャッシュに落とすのは圏外のときだけ、という割り切りです)。

ふたつ、インストールの addAll は全か無か。1 ファイルでも失敗したら事前キャッシュ全体を諦めます。中途半端に揃ったキャッシュは「オフラインで動くように見えて途中で壊れる」という最悪の状態を作るからで、失敗しても次の訪問でやり直されるだけです。

みっつ、skipWaiting() を呼ばない。遊んでいる最中に SW の版が入れ替わると、表示中のページが参照しているアセットを消しかねません。新しい版はタブを閉じた次の訪問から。ゲームの 1 プレイは長くても 30 分なので、この遅延は許容できます。逆に初回だけは clients.claim() で、開いているページを即座に制御下に入れます。skipWaiting なしと claim ありは矛盾ではなく、「初回は即オフライン化、更新は次回訪問から」という組み合わせです。

ハマった点: 設計の穴は、ブログを書いていて見つかった

公開からしばらくして、修学旅行向けのブログ記事を書いたときのことです。「圏外でも、1 ゲーム終わったら次のゲームへ渡り歩ける」と書こうとして、公開前のレビューで止まりました——ゲーム終了画面の「ほかのゲーム」への導線はトップページ経由で、トップページは事前キャッシュに入っていない。つまり圏外では、いま遊んでいるゲームは最後まで動くけれど、終了画面から次のゲームへは行けない。「入れないものから決める」と自分で絞った precache の、自分で気づいていなかった帰結です。

対処は 2 段にしました。設計は変えない——トップを入れ始めると「ゲーム画面を動かす分だけ」という線引きが崩れ、通信量の膨張が止まらなくなります。代わりにユーザー向けの案内を直しました。圏外で複数のゲームを遊びたいなら、遊ぶ予定のゲームのタブを電波のあるうちに開いておき、機内モードにしてから一度リハーサルする。この案内は修学旅行の記事に入っています。学びとして残ったのは、この穴がコードレビューでも E2E でも出なかったことです。「ユーザーの動線を文章に書き下ろす」という作業が、fetch ハンドラの分岐図では見えない結合を照らした——ブログがテストになった、というのが正直な実感です。

まとめ

振り返ると、このオフライン対応で書いた「機能」はほとんどありません。やったことの大半は、事前キャッシュに入れないものを列挙し、介入しない範囲を決め、版が変わらない条件を固定する——壊れ方を先に列挙する作業でした。走行中のバスで知ったかリレー (全員が嘘の解説をでっち上げるゲーム) が最後まで動くのは、キャッシュが賢いからではなく、キャッシュに仕事をさせていないからです。

この線引きが効くのは、事前キャッシュの対象を「ゲーム画面を動かす分だけ」に絞れる規模だからだと思います。ページの種類やアセットがもっと増えるサイトでは、境界の数も増え、列挙だけで守りきれるかは分かりません。それでも、機能を足す前に壊れ方を先に数える順番は、対象が小さいうちほど効くはずです。現物は mawashite.com で動いているので、機内モードにしてから試してもらえるとうれしいです。