PWAの自動更新が「古い版のまま」になる原因とキャッシュ制御(_headers / vite-plugin-pwa)

概要

今回はPWAの自動更新が「古い版のまま」止まってしまう原因と、_headersによるキャッシュ制御について紹介していきます。

vite-plugin-pwaregisterType: "autoUpdate"を入れたから、デプロイすれば全員に最新版が届く——そう思っていたのに、なぜか一部の端末でいつまでも古い画面が表示され続ける。
そんな経験ないですか?(- -;

実はこれ、Service Workerの設定ミスではなく、CDNやブラウザのHTTPキャッシュが古いsw.jsindex.htmlを掴み続けているのが犯人なことが多いんです。
この記事では、なぜそうなるのかを「キャッシュは2層ある」という視点で整理して、public/_headersを1枚置くだけで解決する方法を解説します。
ケースA(Vite + content hash)とケースB(素のHTML静的サイト)の両方を扱うので、自分の構成に合うほうを選んでください。

それではやっていきましょう(^^!

目次

vite-plugin-pwaの自動更新は「もう効いている」(前提整理)

まず大前提として、vite-plugin-pwaを入れていれば、「デプロイで自動更新」という仕組み自体はすでに効いています
なので今回の「古い版のまま」という不具合は、Service Workerの設定ミスが原因ではありません。
この節ではまず「どこまでが自動で動いているのか」を整理して、いじらなくていい部分をはっきりさせておきます。
そうすると、次の節からの本題(キャッシュ制御)に迷わず集中できます。

PWA化そのものの手順は別記事にまとめているので、まだの方はこちらをどうぞ。

Vite + Reactのサイト(SPA)をPWA化する手順(vite-plugin-pwa)

自動更新が成立する条件は次の3つです。
といっても身構える必要はなくて、自分で書くのは最初の2つ(数行)だけ。
3つ目はViteが標準でやってくれるので、意識しなくても勝手にそろいます。

  • vite.config.tsregisterType: "autoUpdate"を書く
    • 「新しい版を見つけたら自動で当てにいく」モードに切り替わり、待機なしの即時反映(skipWaiting / clientsClaim)と古いキャッシュの自動掃除(cleanupOutdatedCaches)が、この一行でまとめて有効になる
  • main.tsxregisterSW({ immediate: true })を呼ぶ
    • Service Workerを登録する処理で、immediate: trueを付けるとページを開くたびに「新しい版が出ていないか」の確認が走る
  • ③ JS / CSS / 画像がcontent hash付きで出力される
    • app-a1b2c3.jsのように中身が変わるとファイル名も変わるViteの標準動作で、新旧が別ファイル扱いになるため、更新の検出と旧キャッシュの破棄が安全に回る

登録側のコードはこれくらいシンプルです。

main.tsx

1
2
3
import { registerSW } from "virtual:pwa-register";

registerSW({ immediate: true });

この状態だと、デプロイ後にユーザーが訪問・画面遷移したタイミングで、おおむね次の流れが自動で回ります。

vite-plugin-pwaの自動更新フロー(デプロイから最新版反映まで)
PWAの自動更新フロー。デプロイ→sw.js更新検出→install→activate(skipWaiting)→reload→旧キャッシュ削除の6ステップ

新しいsw.jsが見つかったらinstallactivateまで一気に進み(skipWaitingで待機をスキップ)、vite-plugin-pwawindow.location.reload()まで自動でやってくれます。
古いキャッシュはcleanupOutdatedCaches()が掃除するので、ここまでは追加実装なしで成立するわけですね。

なぜ古いバージョンが掴まれるのか(HTTPキャッシュの落とし穴)

ここまで効いているのに古い版が残るのは、フロー図の「sw.js更新検出」の一歩手前で詰まっているからです。

自動更新は「新しいsw.jsを検出できること」が出発点になっています。
ところがsw.jsindex.htmlそのものを、CDNやブラウザが古いコピーのままキャッシュして返してしまうと、ブラウザはいつまでも「更新なし」と判断してしまうんです。

入口ファイル(sw.js / index.html)が長期キャッシュされていると、中身を新しくデプロイしても「更新の入口」に気づけません。
結果、端末ごとに反映タイミングがバラついたり、いつまでも古い版が表示され続けたりします。

ホスティングやCDNの既定キャッシュは、sw.jsを数分〜数時間掴むケースもあります。
デプロイ直後に「自分の端末では新しいのに、別の端末では古いまま」が起きるのは、たいていこれが原因です。

キャッシュは2層ある(ここが混乱の元)

対策の前に、ここだけは押さえておきたいポイントがあります。
PWAのキャッシュは性質のまったく違う2つの層に分かれている、ということです。

HTTPキャッシュ層とService Worker Cache層の2層構造
キャッシュの2層構造。①HTTPキャッシュ層(ブラウザ+CDN、_headersで制御)と②Service Worker Cache層(Workbox precache、オフライン担保、_headersと独立)
  • ① HTTPキャッシュ層(ブラウザ + CDN)
    • Cache-Controlヘッダで挙動が決まる層。今回_headersで調整するのはここだけ
  • ② Service Worker Cache層(Cache Storage)
    • Workboxが管理するオフライン用キャッシュ。_headersとは独立して動く

混乱しがちなのは、_headersが触れるのは①だけという点です。
オフライン動作を支えているのは②なので、①の設定をいじってもオフライン機能は壊れません。
このあと「no-cacheにしてオフラインが死なないの?」という不安が出てきますが、層が別物だと分かっていれば怖くないですよ(^^

対策:_headers で入口ファイルを no-cache にする

さてここまで色々やってきましたが、いよいよ対策です。

やることはシンプルで、public/_headersを1枚置いて、入口ファイルを毎回再検証させるだけです。
_headersはCloudflare Pages / Netlifyが標準でサポートしている形式で、public/直下に置けばビルド時にdist/へコピーされて自動認識されます。
構成によって正解が逆になるので、ケースAとケースBに分けて見ていきましょう。

ケースA:Vite + content hash付きアセット

vite-plugin-pwaを使ったSPAはこちらです。
入口ファイルだけno-cacheにして、content hash付きのアセットは思いきり長期キャッシュします。

public/_headers

1
2
3
4
5
6
7
8
9
10
11
/sw.js
Cache-Control: no-cache

/index.html
Cache-Control: no-cache

/manifest.webmanifest
Cache-Control: no-cache

/assets/*
Cache-Control: public, max-age=31536000, immutable

それぞれの狙いはこんな感じです。

  • /sw.jsno-cache
    • 更新の入口。毎回再検証して、新バージョンを即座に検出できるようにする
  • /index.htmlno-cache
    • 常に最新のコードを参照させる。読み込むアセットの一覧もここで切り替わる
  • /manifest.webmanifestno-cache
    • アプリ名やアイコンの変更をすぐ反映させる
  • /assets/*immutable(1年キャッシュ)
    • content hashでビルドごとに別名化されるので、変わったら別ファイル。古いものを掴む心配がなく、再ダウンロードも不要

ポイントは、軽い入口ファイルだけ毎回チェックさせ、重いアセットは完全キャッシュに任せるという役割分担です。
no-cacheでも未変更なら304 Not Modifiedが返るだけなので、転送コストはほぼかかりません。

ケースB:素のHTML静的サイト(ビルドなし)

ビルド工程がなく、style.cssapp.jsのファイル名が固定のサイトはこちらです。
content hashが無い=「不変アセット」が存在しないので、ケースAとは戦略が逆転します。

public/_headers

1
2
3
4
5
/*
Cache-Control: no-cache

/images/*
Cache-Control: public, max-age=86400, stale-while-revalidate=604800
  • /*no-cache(全体の基準)
    • 固定ファイル名のため、全部を毎回再検証させないと更新を検出できない
  • /images/*だけ緩める
    • めったに変わらない画像は1日キャッシュ。差し替え時は最大1日反映が遅れるが、転送量を抑えられる

固定名ファイルにimmutableを付けてしまうと、更新しても永遠に古いまま掴まれるので厳禁です。
ここがケースAと真逆になるところで、hashがあるなら入口だけno-cache、hashが無いなら全部no-cacheと覚えておくと迷いません。
ちなみにオフラインが不要な静的サイトなら、Service Worker自体を入れない判断もアリです(^^b

よくある誤解:no-cacheにするとオフラインが壊れる?

ここまで読んで「全部no-cacheにしたら、オフラインで動かなくなるのでは?」と不安になった方、安心してください。
結論から言うと、オフライン動作は壊れません。

理由は2つあります。

  • no-cacheno-store
    • no-cacheは「保存はする。ただし使う前に必ずサーバへ再検証する」という意味。no-store(保存すらしない)とは別物
  • オフライン時はそもそもサーバに到達しない
    • 再検証先のサーバに届かないのでCache-Controlは評価されず、②のService Worker Cache層が代わりに配信する

さっきの2層構造を思い出すと腑に落ちますね。
_headersが触るのは①のHTTPキャッシュ層だけで、オフラインを支える②とは無関係なんです。

オフライン環境で何が動いて何が動かないかをまとめるとこうなります。

機能 オフライン 理由
アプリ本体(画面・操作) SWのprecacheから配信される
ローカル保存データ localStorage等はネット不要
Webフォント(訪問済み) woff2をCacheFirstでキャッシュ済み
解析・フォーム送信・広告 元からオフラインでは動かない。再オンライン時に動く

「アプリが開いて、データが読み書きできる」というオフラインの本体はno-cacheにしても無傷です。
解析やフォーム送信がオフラインで止まるのは元々の挙動なので、_headersのせいではありません。

検証方法(DevToolsで確認)

設定したら、本当に効いているか確認しましょう。
手順はこんな感じです。

  • ① ビルドで_headersが出力されているか
    • npm run buildしてdist/_headersが生成されることを確認(public/dist/へコピーされる)
  • ② プレビューでSW登録を確認
    • npm run previewで開き、DevTools→Application→Service Workersに登録されているかを見る
  • ③ 1行変えて再ビルド→リロード
    • 既存タブをリロードして、新しいhashのアセットが読み込まれ、旧キャッシュが消えることを確認
  • ④ 本番URLのNetworkでヘッダを確認
    • sw.jscache-control: no-cache/assets/*-<hash>.jsimmutableが乗っていればOK

目視だけだと見落としがちなので、機械的にチェックしたいときは監査スクリプトを併用すると楽できます!

横展開のチェックリスト(ハマりやすい所)

別プロジェクトに同じ仕組みを入れるときに、つまずきやすいポイントをまとめておきます。

  • base path配信のときはパスを合わせる
    • GitHub Pagesのサブパス等では_headersのパスを/<base>/sw.jsのように実態へ合わせる
  • 固定名ファイルにimmutableを乗せない
    • content hashが無いファイルに長期キャッシュを付けると、永遠に古いまま掴まれる
  • .gitignorepublic/_headersを除外しない
    • コミット漏れで_headersがデプロイされず、設定が効かない事故が起きがち
  • 旧SWが残った端末は最終手段でUnregister
    • どうしても残る場合はDevTools→Application→Service Workers→Unregisterで強制リセット
  • registerType: "prompt"は自動リロードされない
    • 更新トーストを出して手動更新させる方式。入力中の自動リロードを避けたいフォーム系アプリ向け

Cloudflare Pages / Netlifyなら_headersをそのまま置くだけですが、Firebase / Vercel / S3などは_headers非対応なので、各ホストの設定で同等のCache-Controlを指定してみましょう!

まとめ

いかがでしたでしょうか?
PWAの「デプロイで全員に最新版」を確実にするコツを、サクッとまとめるとこんな感じです。

  • 自動更新の入口はsw.js / index.html
    • ここが古くキャッシュされると更新に気づけない
  • 対策はpublic/_headersを設定
    • 入口をno-cache、hash付きアセットはimmutable
  • キャッシュは2層
    • _headersが触るのはHTTP層だけ。オフラインは別層なので壊れない
  • 構成によってやり方が違う
    • hashありは入口だけno-cache、hashなしは全部no-cache

vite-plugin-pwaの自動更新が「効いているはずなのに反映されない」ときは、たいていHTTPキャッシュが犯人です。
_headersを1枚足すだけで「ユーザーにキャッシュクリアを頼まない」状態に持っていけるので、PWAを公開している方はぜひ入れてみてください。

PWA化そのものの手順はこちらにまとめています。あわせてどうぞ(^^

Vite + Reactのサイト(SPA)をPWA化する手順(vite-plugin-pwa)

以上となります。
PWA化はちょっと面倒ですが、メリットは大きいのでやるしかない!
それではお疲れさまでした!