PWAで「2回再起動しないと更新されない」問題を解決した話
vite-plugin-pwaを使ったPWAで、オフライン起動エラーと更新の遅延という2つの問題にハマった。Service Workerの仕組みを理解して解決した記録。
PWAを作っていて、2つの問題に遭遇しました。
- iOSでオフライン起動するとエラーになる
- オンラインでも2回再起動しないと最新版が反映されない
どちらもService Workerの仕組みを理解していなかったことが原因でした。
そもそもService Workerとは
Service Workerは、ブラウザとネットワークの間に立つプロキシのような存在です。
[ブラウザ] ⇄ [Service Worker] ⇄ [ネットワーク]
通常、ブラウザがリソースを取得するときはネットワークに直接リクエストを送ります。しかしService Workerを登録すると、すべてのリクエストがService Workerを経由するようになります。
Service Workerができること
- リクエストの横取り: ブラウザからのリクエストをインターセプトして、キャッシュから返したり、加工したりできる
- オフライン対応: ネットワークが使えないときでも、キャッシュからリソースを返せる
- バックグラウンド処理: ページが閉じていてもプッシュ通知の受信などが可能
PWAにおける役割
PWAがネイティブアプリのように動作できるのは、Service Workerのおかげです。
- オフライン起動: HTML/CSS/JSをキャッシュしておき、オフラインでも返せる
- 高速表示: ネットワークを待たずにキャッシュから即座に表示できる
- バージョン管理: 新しいアプリをバックグラウンドでダウンロードし、準備ができたら切り替える
開発者視点での最大のメリット
Service Workerの素晴らしいところは、アプリ側のコードを変更する必要がないという点です。
普通のWebアプリを作るときと同じようにfetch()でAPIを叩き、<img>タグで画像を読み込み、<a>タグでページ遷移する。Service Workerはそのリクエストを裏で横取りして、キャッシュから返したりオフライン時にフォールバックしたりしてくれます。
// アプリ側のコード(Service Workerを意識する必要なし)
const data = await fetch('/api/events');
つまり、通常のWeb開発の知識だけでネイティブアプリに近い体験を実現できるのです。「オフライン対応のために特別なAPIを使う」といった学習コストはほとんどありません。
ただし、この「バージョン管理」の仕組みが独特で、今回ハマった原因でもありました。
問題1: iOSでオフライン起動できない
iPhoneでPWAをホーム画面に追加して使っていたところ、機内モードにしてアプリを開くと「iPhoneがインターネットに接続されていないため、ページをSafariで開くことができません。」というエラーが表示されました。
PWAはオフラインでも動くはずなのに、なぜ?
SPAとService Workerの関係
まず前提として、SPAの仕組みを理解する必要があります。
SPAでは、/eventsや/settingsといったURLにアクセスしても、サーバーには実際のファイルが存在しません。すべてのパスでindex.htmlを返し、JavaScriptがURLを見てルーティングを行います。
リクエスト: /events
サーバー: index.htmlを返す
JavaScript: URLが/eventsなのでEventsページを表示
これはオンラインなら問題ありません。サーバーが「どのパスでもindex.htmlを返す」設定になっているからです。
オフライン時の問題
ところがオフライン時、Service Workerがリクエストを処理します。ここで問題が発生します。
ユーザーが/eventsを開こうとすると、Service Workerは「/eventsというファイルをキャッシュから探す」という動作をします。しかし、そんなファイルはキャッシュにありません(キャッシュにあるのはindex.html)。
リクエスト: /events
Service Worker: /eventsをキャッシュから探す → 見つからない → エラー!
navigateFallbackの役割
navigateFallbackは、「ナビゲーションリクエスト(HTMLページへのリクエスト)がキャッシュになかったら、代わりにこのファイルを返す」という設定です。
workbox: {
navigateFallback: "/index.html",
}
これにより、/eventsへのリクエストがキャッシュになくても、index.htmlが返されます。
リクエスト: /events
Service Worker: /eventsをキャッシュから探す → 見つからない → index.htmlを返す
JavaScript: URLが/eventsなのでEventsページを表示
navigateFallbackAllowlistが必要な理由
navigateFallbackだけでは不十分な場合があります。特にiOSのPWAでは、どのURLでフォールバックを許可するか明示的に指定しないと、オフライン時にナビゲーションが正しく処理されないことがあります。
workbox: {
navigateFallback: "/index.html",
navigateFallbackAllowlist: [/.*/], // すべてのパスでフォールバックを許可
}
なお、APIへのリクエストは通常別ドメイン(api.example.comなど)に向けて行われるため、同一オリジンのナビゲーションフォールバックには影響しません。
apple-mobile-web-app-capableメタタグ
iOSでPWAをフルスクリーン表示するために必要なメタタグです。
<meta name="apple-mobile-web-app-capable" content="yes" />
このタグがないと、iOSではPWAがSafariのUIを表示したまま動作し、Service Workerの挙動が不安定になることがあります。
最終的な設定
workbox: {
navigateFallback: "/index.html",
navigateFallbackAllowlist: [/.*/],
}
これでiOSでもオフライン起動できるようになりました。
問題2: 2回再起動しないと更新されない
アプリを更新してデプロイしても、ユーザーが1回アプリを開いただけでは古いバージョンが表示されます。2回開いてようやく最新版が見える。
これはService Workerのライフサイクルによる仕様です。
Service Workerのライフサイクル
Service Workerには3つの状態があります。
[installing] → [waiting] → [active]
- installing: 新しいService Workerがダウンロード・インストール中
- waiting: インストール完了したが、古いService Workerがまだ使われている
- active: このService Workerがページを制御している
重要なのは、新しいService Workerはすぐにactiveにならないということです。
なぜすぐに切り替わらないのか
これは安全のための設計です。
古いService Workerで動いているページがある状態で、新しいService Workerに切り替えると、キャッシュの整合性が崩れる可能性があります。古いHTMLが新しいJavaScriptを参照しようとして、存在しないファイルを読み込もうとするかもしれません。
そのため、Service Workerは「すべてのタブが閉じられるまで待機する」という保守的な戦略をとります。
1回目の起動:
古いSW(active) が index.html を返す
新しいSW がダウンロードされる → waiting状態に
(ユーザーがアプリを閉じる)
2回目の起動:
新しいSW(active) が最新の index.html を返す
解決策1: skipWaitingで待機をスキップ
skipWaitingを有効にすると、新しいService Workerは待機状態をスキップして、すぐにアクティブになります。
workbox: {
skipWaiting: true,
}
ただし、これだけでは不十分です。アクティブになっても、すでに開いているページは古いService Workerに制御されたままです。
解決策2: clientsClaimで即座に制御を奪う
clientsClaimを有効にすると、Service Workerがアクティブになった瞬間に、すべてのページの制御を奪います。
workbox: {
skipWaiting: true,
clientsClaim: true,
}
これで、新しいService Workerがインストールされると:
skipWaiting→ waiting状態をスキップしてすぐactiveclientsClaim→ すでに開いているページも新しいSWの制御下に
解決策3: useRegisterSWで更新を検知してリロード
Service Workerが切り替わっても、ページのHTMLやJavaScriptは古いままです。最新のアプリを表示するには、ページをリロードする必要があります。
useRegisterSWフックを使って、Service Workerの更新を検知したら自動でページをリロードします。
// useServiceWorkerUpdate.ts
import { useRegisterSW } from "virtual:pwa-register/react";
export function useServiceWorkerUpdate(): void {
useRegisterSW({
onNeedRefresh() {
// 新しいService Workerが準備できたら自動リロード
window.location.reload();
},
onRegisteredSW(_swUrl, registration) {
// 1時間ごとに更新をチェック
if (registration) {
setInterval(() => {
registration.update();
}, 60 * 60 * 1000);
}
},
});
}
onNeedRefreshとは
onNeedRefreshは「新しいService Workerがインストールされて、ページをリロードすれば最新版が使える状態になった」ときに呼ばれるコールバックです。
ここでwindow.location.reload()を呼ぶことで、ユーザーに意識させることなく最新版に切り替わります。
registration.update()とは
Service Workerの更新チェックは、通常ナビゲーション時(ページ遷移時)に行われます。しかし、SPAではページ遷移がないため、長時間開きっぱなしにすると更新チェックが行われません。
registration.update()を定期的に呼ぶことで、バックグラウンドで更新をチェックします。1時間ごとにチェックする設定にしています。
最終的な設定
workbox: {
skipWaiting: true, // 待機をスキップして即座にアクティブ化
clientsClaim: true, // アクティブ化したら即座にクライアントを制御
// ...
}
改善後のフロー
- ユーザーがアプリを開く
- 裏で新しいService Workerがあるかチェック
- 新しいSWが見つかる → インストール →
skipWaitingで即座にアクティブ →clientsClaimで制御を奪う onNeedRefreshが呼ばれる → 自動でページがリロード- 最新版が表示される
これで1回の起動で最新版が反映されるようになりました。
依存パッケージ
useRegisterSWを使うにはworkbox-windowパッケージが必要です。
pnpm add workbox-window
また、型定義のためにvite-env.d.tsに以下を追加します。
/// <reference types="vite-plugin-pwa/client" />
まとめ
PWAの落とし穴は、ブラウザでは動いていてもPWAとして動かすと問題が出るケースがあることです。特にiOSは挙動が独特なので、実機でのテストが重要です。
Service Workerのライフサイクルを理解していないと「なんで更新されないんだ?」とハマりがちですが、仕組みがわかれば対処は難しくありません。
今回の対応をまとめると:
| 問題 | 原因 | 解決策 |
|---|---|---|
| iOSオフライン起動エラー | ナビゲーションフォールバックの設定不足 | navigateFallbackAllowlistを追加 |
| 2回再起動しないと更新されない | Service Workerのライフサイクル | useRegisterSWで自動リロード |
PWAは便利ですが、Service Workerの挙動を把握しておかないと思わぬところでハマります。この記事が同じ問題に遭遇した方の参考になれば幸いです。