メインコンテンツへスキップ
ブログ一覧に戻る
Web制作・運用

PWA入門:インストール、オフライン、更新と通知を分けて設計する

2025年10月19日
最終更新: 2026年10月8日
8分で読めます

この記事でわかること

PWAの各機能は別々に設計・検証する必要があります。公開ページだけを保存する最小例から、インストール条件、更新の待機、通知、個人データの扱いまで説明します。

2026年10月8日にGoogle web.devのLearn PWA本文を再確認した。PWAは単一コードベースで到達性・信頼性・インストール性を高めるWebアプリで、基礎となるレスポンシブ設計・性能・アクセシビリティの上に、manifest、service worker、Cache Storage、オフライン戦略を積み上げる。オフライン表示はデータの最新性や同期を意味せず、更新、失敗、権限、ストレージ、通知の制約を利用者へ伝え、インストールできることを成果と取り違えない。

30秒で要点

PWA(Progressive Web App)は、Webの技術を使い、必要に応じてインストールやオフラインなどの体験を加えるアプリの作り方です。1つの機能を有効にすれば、ネイティブアプリと同じ動作がすべて手に入るわけではありません。

  • インストールできること、オフラインで読めること、通知が届くことは別の条件です。
  • 保存する対象、更新方法、個人データの扱いを明示します。
  • 機能検出と実機確認を行い、使えない環境でも基本の操作を保ちます。

この記事では、通常のWebページから段階的に機能を追加する判断と、公開ページを保存する小さな例を説明します。画面の対応幅はレスポンシブデザインも含めて設計してください。

4つの機能を、同じものとして扱わない

表が収まらない場合は、横にスクロールできます

機能主な仕組み別途必要な判断
ホーム画面などへの追加マニフェスト、ブラウザやOSの機能対象環境の要件、アイコン、起動先、案内方法
オフラインの画面表示Service Worker、Cache APIなど保存対象、初回未保存、古い内容、容量不足
アプリの更新ファイル配信、Service Workerのライフサイクル旧画面との共存、切替時の入力保存、戻し方
Web PushPush API、Service Worker、配信側の処理利用者の許可、購読管理、配信失敗、通知内容

インストールはオフライン動作を保証しません。また、Service Workerがすべてのインストール条件で必須というわけでもありません。MDNのインストールガイドを基に、対象環境の要件を確認します。

初回オンラインで公開ページを保存し、後の通信失敗時に保存済みページを使う経路。未保存ならオフライン表示不可。新版は保存に成功してから待機し、旧画面の利用終了後に切り替わる
図1:保存、オフライン時の利用、新版への切替は別の段階。ホーム画面への追加だけでは、必要なファイルの保存も更新の成功も保証されない。

インストールの案内は、対象環境に合わせる

マニフェストでは、アプリ名、識別子、起動先、表示方法、アイコン、アプリとして扱うURL範囲などを定めます。たとえばChromium系のインストール要件には、名前、起動先、表示方法、必要なサイズのアイコンなどがあります。要件は実装によって異なるため、固定した対応表だけに頼らないでください。

start_urlとscope、Service Workerの制御範囲は、それぞれ意味が違います。ログイン後の画面から範囲外へ移動するときの見え方も確認します。HTTPSなどの安全なコンテキストが必要な機能があり、ローカル開発には例外が認められる環境があります。

beforeinstallpromptを前提にした独自ボタンは、すべての環境で同じように使えません。対応している場合だけ表示し、非対応環境にはブラウザの操作に合った案内を用意します。利用者が追加しなくても、通常のWebページとして読めることを保つ設計が基本です。

ストアへの配布が必要なら、パッケージ化の方法、審査、利用するAPI、運用体制を別途確認します。「PWAならどのストアでも配布できる」「ストアへは一切配布できない」のどちらも、一律の判断には使えません。

公開HTML1件だけを保存する最小例

以下は、ログインや個人別応答のない静的な公開ページを想定した例です。空のディレクトリに2ファイルを置きます。Webアプリ全体の完成例ではなく、保存する範囲を絞る考え方を示します。

index.htmlは次の内容です。実際にブラウザで試す場合は、HTTPSまたはService Workerが許可されるローカル開発環境で配信する必要があります。ファイルを直接開くだけでは検証できません。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>公開資料のオフライン例</title>
</head>
<body>
  <main>
    <h1>公開資料</h1>
    <p>このページは個人情報を含まない学習用の資料です。</p>
    <p id="status" role="status">通常のWebページとして表示しています。</p>
  </main>
  <script>
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('./sw.js').then(() => {
        document.querySelector('#status').textContent =
          '登録を受け付けました。保存・制御の開始は別途確認してください。';
      }).catch(() => {
        document.querySelector('#status').textContent =
          'オフライン機能を登録できませんでした。';
      });
    }
  </script>
</body>
</html>

同じディレクトリのsw.jsです。扱うのはクエリ文字列なしのindex.htmlへのGETによる画面移動だけです。/や別ページへのアクセスは、この例では対象にしません。

const PREFIX = 'public-guide-demo-';
const CACHE = `${PREFIX}v1`;
const PAGE = new URL('./index.html', self.location.href).href;

self.addEventListener('install', (event) => {
  event.waitUntil((async () => {
    const cache = await caches.open(CACHE);
    await cache.add(new Request(PAGE, {
      credentials: 'omit', cache: 'reload',
    }));
  })());
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    const names = await caches.keys();
    await Promise.all(names
      .filter((name) => name.startsWith(PREFIX) && name !== CACHE)
      .map((name) => caches.delete(name)));
  })());
});

self.addEventListener('fetch', (event) => {
  const request = event.request;
  if (request.method !== 'GET' || request.mode !== 'navigate' ||
      request.url !== PAGE) return;

  event.respondWith((async () => {
    try {
      return await fetch(request);
    } catch {
      const cache = await caches.open(CACHE);
      return await cache.match(PAGE) ?? new Response(
        '保存済みの公開資料がありません。接続後に再度開いてください。',
        { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
      );
    }
  })());
});

保存時の取得に失敗するとinstallは失敗します。最初の読み込みから必ず制御されるわけではないため、登録の成功だけを「オフラインで使えます」という表示にしません。制御範囲や更新時の待機はService Workerのライフサイクルを確認してください。

この例は通信例外のときだけ保存済みページへ切り替えます。HTTP 404や500は通信例外とは異なり、その応答を返します。応答が長く止まる場合のタイムアウトも追加していません。取得結果をすべて自動保存する処理を入れていないため、APIや個人別ページが混入する範囲を狭めています。

公開ページでも保存期間を定める必要があります。Cache APIはHTTPのキャッシュヘッダーに従って自動失効する仕組みではありません。削除対象はこの例の接頭辞を持つキャッシュに限定し、同じオリジンにある別アプリのキャッシュを消さないようにしています。

新しい版が配られても、すぐ全画面が切り替わるとは限らない

Service Workerは、古い画面が動いている間、新しい版を待機させることがあります。ページの更新操作だけでは、古い版を利用する画面がすべて終了した状態にならない場合もあります。

新版で保存するHTMLやJavaScriptを変更するときは、版ごとのファイルとキャッシュを揃えます。この例を更新するなら、キャッシュ名も変更して新たに保存します。新しいファイルの取得に失敗した場合、古い版を保つ方が適切か、利用を止めるべきかを情報の性質に応じて決めます。

skipWaiting()で待機を飛ばすと、旧画面と新版の処理が混在し得ます。入力中のフォームがある場合などは、新版があることを案内し、保存や再読み込みのタイミングを利用者と調整します。clients.claim()も初回画面を途中で制御するため、理由なく付ける定型処理にはしません。

公開停止すべき内容や機密情報を、端末のオフライン保存から即座に回収できると考えてはいけません。古い端末や長期間未接続の利用者を含め、何を保存してよいかを先に判断します。

オフラインの入力と通知には、別の設計が必要

読むだけの資料と、注文や申請を送る操作は区別します。オフラインの入力を端末へ保管するなら、未送信であること、保存先、削除方法、再送条件を表示します。接続が戻ったときの競合や二重送信も扱います。端末保存を、サーバーでの受付完了と同じ表示にしてはいけません。

navigator.onLineも、目的のAPIへ到達できる証拠にはなりません。ネットワークの状態は手掛かりとして使い、操作結果は実際の要求で判断します。MDNの説明も、その不確かさを注意点に挙げています。

Web Pushでは、通知が必要な理由を示したうえで許可を求めます。WebKitのiOS・iPadOS16.4向け説明では、ホーム画面に追加したWebアプリと、利用者の操作に応じた許可要求が条件です。2023年の導入説明を、将来も含む全環境の対応保証には使わず、実際の対象端末で確認します。

配信側では購読の更新・失効、送信失敗、通知を止める操作を扱います。通知本文にはロック画面へ出てもよい情報だけを載せ、重要な業務結果はアプリ内でも確認できるようにします。

導入前に、状態を変えて確認する

本稿の掲載例は、Node.js上の構文と模擬的なCache・fetchの分岐を確認したものです。実ブラウザでのインストール、Service Workerの制御、更新、通知の動作確認とは分けて扱います。

表が収まらない場合は、横にスクロールできます

確認する状態観察すること
初めて訪問してから切断保存前に切れても誤った完了表示をしないか
保存後、制御中に切断必要な画面が読めるか。操作できない範囲が分かるか
古い画面を開いたまま更新新旧のファイルが不整合にならないか。入力が失われないか
キャッシュ削除・容量不足保存がなくても理由と復帰方法を示せるか
ログイン・ログアウト・利用者変更個人別データが別の利用者へ残らないか
通知を拒否・購読を解除基本機能が使え、通知停止が反映されるか

ネイティブアプリと比較する際は、必要な端末API、バックグラウンド動作、配布方法、対象端末、運用体制を基準にします。開発費やコンバージョンの一定割合の改善は保証できません。まず1つの利用場面で、通信が弱いときに何を継続できれば役立つのかを決め、その範囲から試してください。

よくある質問(FAQ)