PWAの自動更新が「古い版のまま」になる原因とキャッシュ制御(_headers / vite-plugin-pwa)
概要
今回はPWAの自動更新が「古い版のまま」止まってしまう原因と、_headersによるキャッシュ制御について紹介していきます。
vite-plugin-pwaでregisterType: "autoUpdate"を入れたから、デプロイすれば全員に最新版が届く——そう思っていたのに、なぜか一部の端末でいつまでも古い画面が表示され続ける。
そんな経験ないですか?(- -;
実はこれ、Service Workerの設定ミスではなく、CDNやブラウザのHTTPキャッシュが古いsw.jsやindex.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.tsにregisterType: "autoUpdate"を書く- 「新しい版を見つけたら自動で当てにいく」モードに切り替わり、待機なしの即時反映(
skipWaiting/clientsClaim)と古いキャッシュの自動掃除(cleanupOutdatedCaches)が、この一行でまとめて有効になる
- 「新しい版を見つけたら自動で当てにいく」モードに切り替わり、待機なしの即時反映(
- ②
main.tsxでregisterSW({ immediate: true })を呼ぶ- Service Workerを登録する処理で、
immediate: trueを付けるとページを開くたびに「新しい版が出ていないか」の確認が走る
- Service Workerを登録する処理で、
- ③ JS / CSS / 画像がcontent hash付きで出力される
app-a1b2c3.jsのように中身が変わるとファイル名も変わるViteの標準動作で、新旧が別ファイル扱いになるため、更新の検出と旧キャッシュの破棄が安全に回る
登録側のコードはこれくらいシンプルです。
main.tsx
1 | import { registerSW } from "virtual:pwa-register"; |
この状態だと、デプロイ後にユーザーが訪問・画面遷移したタイミングで、おおむね次の流れが自動で回ります。
新しいsw.jsが見つかったらinstall→activateまで一気に進み(skipWaitingで待機をスキップ)、vite-plugin-pwaがwindow.location.reload()まで自動でやってくれます。
古いキャッシュはcleanupOutdatedCaches()が掃除するので、ここまでは追加実装なしで成立するわけですね。
なぜ古いバージョンが掴まれるのか(HTTPキャッシュの落とし穴)
ここまで効いているのに古い版が残るのは、フロー図の「sw.js更新検出」の一歩手前で詰まっているからです。
自動更新は「新しいsw.jsを検出できること」が出発点になっています。
ところがsw.jsやindex.htmlそのものを、CDNやブラウザが古いコピーのままキャッシュして返してしまうと、ブラウザはいつまでも「更新なし」と判断してしまうんです。
結果、端末ごとに反映タイミングがバラついたり、いつまでも古い版が表示され続けたりします。
ホスティングやCDNの既定キャッシュは、sw.jsを数分〜数時間掴むケースもあります。
デプロイ直後に「自分の端末では新しいのに、別の端末では古いまま」が起きるのは、たいていこれが原因です。
キャッシュは2層ある(ここが混乱の元)
対策の前に、ここだけは押さえておきたいポイントがあります。
PWAのキャッシュは性質のまったく違う2つの層に分かれている、ということです。
- ① HTTPキャッシュ層(ブラウザ + CDN)
Cache-Controlヘッダで挙動が決まる層。今回_headersで調整するのはここだけ
- ② Service Worker Cache層(Cache Storage)
- Workboxが管理するオフライン用キャッシュ。
_headersとは独立して動く
- Workboxが管理するオフライン用キャッシュ。
混乱しがちなのは、_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 | /sw.js |
それぞれの狙いはこんな感じです。
/sw.jsをno-cache- 更新の入口。毎回再検証して、新バージョンを即座に検出できるようにする
/index.htmlをno-cache- 常に最新のコードを参照させる。読み込むアセットの一覧もここで切り替わる
/manifest.webmanifestをno-cache- アプリ名やアイコンの変更をすぐ反映させる
/assets/*をimmutable(1年キャッシュ)- content hashでビルドごとに別名化されるので、変わったら別ファイル。古いものを掴む心配がなく、再ダウンロードも不要
ポイントは、軽い入口ファイルだけ毎回チェックさせ、重いアセットは完全キャッシュに任せるという役割分担です。no-cacheでも未変更なら304 Not Modifiedが返るだけなので、転送コストはほぼかかりません。
ケースB:素のHTML静的サイト(ビルドなし)
ビルド工程がなく、style.cssやapp.jsのファイル名が固定のサイトはこちらです。
content hashが無い=「不変アセット」が存在しないので、ケースAとは戦略が逆転します。
public/_headers
1 | /* |
/*をno-cache(全体の基準)- 固定ファイル名のため、全部を毎回再検証させないと更新を検出できない
/images/*だけ緩める- めったに変わらない画像は1日キャッシュ。差し替え時は最大1日反映が遅れるが、転送量を抑えられる
固定名ファイルにimmutableを付けてしまうと、更新しても永遠に古いまま掴まれるので厳禁です。
ここがケースAと真逆になるところで、hashがあるなら入口だけno-cache、hashが無いなら全部no-cacheと覚えておくと迷いません。
ちなみにオフラインが不要な静的サイトなら、Service Worker自体を入れない判断もアリです(^^b
よくある誤解:no-cacheにするとオフラインが壊れる?
ここまで読んで「全部no-cacheにしたら、オフラインで動かなくなるのでは?」と不安になった方、安心してください。
結論から言うと、オフライン動作は壊れません。
理由は2つあります。
no-cache≠no-storeno-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.jsにcache-control: no-cache、/assets/*-<hash>.jsにimmutableが乗っていればOK
目視だけだと見落としがちなので、機械的にチェックしたいときは監査スクリプトを併用すると楽できます!
横展開のチェックリスト(ハマりやすい所)
別プロジェクトに同じ仕組みを入れるときに、つまずきやすいポイントをまとめておきます。
basepath配信のときはパスを合わせる- GitHub Pagesのサブパス等では
_headersのパスを/<base>/sw.jsのように実態へ合わせる
- GitHub Pagesのサブパス等では
- 固定名ファイルに
immutableを乗せない- content hashが無いファイルに長期キャッシュを付けると、永遠に古いまま掴まれる
.gitignoreでpublic/_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化はちょっと面倒ですが、メリットは大きいのでやるしかない!
それではお疲れさまでした!