Next.js 15のPWA、8ヶ月運用してService Workerの設計がようやく安定した話

「またキャッシュが古い」とクレームが来るたびに頭を抱えた日々。Next.js 15×PWAの本番8ヶ月でハマった落とし穴と、やっと落ち着いた設計をそのまま共有します。

Service Workerが「また壊れた」と言われた日

去年の10月頃、チームの Slack でこんなメッセージが届いた。「PWAのキャッシュ、また古いコンテンツが出てる。ユーザーからクレーム来てます」。

Next.js 15 でリリースしたばかりの社内向けツールで、PWA対応を入れて「ネイティブアプリっぽく使えます」と謳ったのに、デプロイのたびにキャッシュが壊れるかそのまま残るかのロシアンルーレット状態。正直あの頃はかなりしんどかった。

それから8ヶ月間、本番でひたすら運用しながら設計を見直してきた。教科書的な話は最小限にして、「実際ここで詰まった」「こういう設計に落ち着いた」という部分を中心に書いていく。

関連する話として、Next.js 15 × React 19 本番6ヶ月でキャッシュ地獄を踏んだ話も書いているので、Next.js のキャッシュ設計全般に興味がある方はそちらも参照してほしい。


2026年のPWAを取り巻く状況整理

まず現状確認から。2026年時点でPWAを取り巻く状況はここ1〜2年でかなり変わった。

主要APIの対応状況

APIChromeFirefoxSafari備考
Service WorkeriOS 17.4以降はサードパーティブラウザでも動作
Web PushSafari 16.4+対応済み
Web App Manifestdisplay: standalone 全般OK
Badging APImacOS 14以降は対応
File System AccessSafariは非対応のまま
Isolated Web Apps (IWA)✅(Dev)Chrome 開発者向けフラグ、本番未展開
Background SynciOSは依然制限あり

Isolated Web Apps(IWA)は2026年現在まだ Chrome の開発者向けフラグが必要で、本番投入できる状況ではない。以前書いたPWA完全ガイドでも触れているが、IWAが実用的になるのは2027年以降になりそうだ。

SafariのBackground Syncは相変わらず制限があって、iOSユーザーが多いサービスだとここは今でも痛い。「Safariだから仕方ない」で済ませられない場面も多く、設計段階でiOS制限を前提に考える必要がある。

技術スタックの変化

嬉しい変化として挙げたいのが Workbox v8 の登場。v7 までと比べると、特に以下の3点が効いた。

改善点内容
Module形式ネイティブ対応バンドルサイズが従来比で約30%削減
Background Sync の改善リトライ戦略がより細かく制御できるように
Strategy composability複数のキャッシュ戦略を組み合わせやすくなった

本番で詰まったService Worker設計の3つの地雷

正直ここが一番伝えたいところ。

地雷1: skipWaiting() を気軽に使った結果

最初の実装では Service Worker の更新を即座に適用させたくて、こんなコードを書いていた。

// sw.js(最初の実装)
self.addEventListener('install', (event) => {
  self.skipWaiting(); // 「すぐ反映したい」という安易な判断
});

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

これの何が問題かというと、ユーザーが複数タブを開いていたとき。タブAは旧バージョン、タブBは新バージョンになって、SPA の状態管理がカオスになった。特に認証トークンのリフレッシュ処理でレースコンディションが発生して、ユーザーが突然ログアウト扱いになるというクレームに直結した。skipWaiting() 一行で地雷を踏む、典型的なパターンだと思う。

今の実装はこう変えた:

// sw.js(現在の実装)
let skipWaitingRequested = false;

self.addEventListener('install', (event) => {
  // skipWaiting は呼ばない。UIから明示的に要求されたときだけ実行
  event.waitUntil(caches.open(CACHE_NAME).then(cache => {
    return cache.addAll(PRECACHE_URLS);
  }));
});

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

UIサイドでは「新しいバージョンが利用可能です。今すぐ更新しますか?」のバナーを出してユーザーに判断させるようにした。

// hooks/useServiceWorker.ts
import { useEffect, useState } from 'react';

export function useServiceWorker() {
  const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null);
  const [showReload, setShowReload] = useState(false);

  useEffect(() => {
    if (!('serviceWorker' in navigator)) return;

    navigator.serviceWorker.ready.then(registration => {
      // 既に waiting 中のWorkerがいる場合
      if (registration.waiting) {
        setWaitingWorker(registration.waiting);
        setShowReload(true);
      }

      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker?.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            setWaitingWorker(newWorker);
            setShowReload(true);
          }
        });
      });
    });
  }, []);

  const reloadPage = () => {
    waitingWorker?.postMessage({ type: 'SKIP_WAITING' });
    navigator.serviceWorker.addEventListener('controllerchange', () => {
      window.location.reload();
    });
  };

  return { showReload, reloadPage };
}

地雷2: キャッシュ戦略の設計ミス

Workbox v8 を使い始めてから戦略は整理できたけど、最初は「全部 NetworkFirst でいいか」という雑な判断をしていた。これはモバイル回線が不安定な環境でユーザー体験が最悪になった。画像すら毎回ネットワークに問いに行くので、電波が悪い場所では何も表示されない状態になった。

今はリソースの種類で戦略を明確に分けている:

// sw.js(Workbox v8)
import { registerRoute } from 'workbox-routing';
import {
  NetworkFirst,
  StaleWhileRevalidate,
  CacheFirst,
} from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';

// APIリクエスト: NetworkFirst(最新データ優先だがオフライン時はキャッシュ)
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 3, // これ重要: 3秒でキャッシュにフォールバック
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 300 }),
    ],
  })
);

// 静的アセット(JS/CSS): StaleWhileRevalidate
registerRoute(
  ({ request }) =>
    request.destination === 'script' ||
    request.destination === 'style',
  new StaleWhileRevalidate({
    cacheName: 'static-resources',
    plugins: [
      new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 }),
    ],
  })
);

// 画像: CacheFirst(変わらないものはガンガンキャッシュ)
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60 }),
    ],
  })
);

networkTimeoutSeconds: 3 の設定が地味に重要で、これがないと電波が悪いときにユーザーはずっとスピナーを見続けることになる。3秒というのもチューニングの余地があるが、うちの場合これが一番しっくりきた。

地雷3: Next.js 15 の App Router との相性問題

Next.js 15 × React 19 本番投入のキャッシュ設計でも触れているが、App Router の RSC(React Server Components)と Service Worker のキャッシュは相性が悪い場面がある。

具体的には、Next.js の prefetch で生成されるリクエストを Service Worker が意図せずキャッシュしてしまうケース。next/navigation<Link> コンポーネントが prefetch する RSC ペイロードをキャッシュすると、ナビゲーション後のデータが古いまま残ることがあった。これに気づくまでかなり時間を溶かした。

対策として、RSCのリクエストだけは Service Worker のキャッシュ対象から外した:

// sw.js
// Next.js のRSCフライトリクエストは除外
registerRoute(
  ({ url, request }) => {
    // RSCのリクエストヘッダーを確認
    const isRSC = request.headers.get('RSC') === '1';
    const isNextRouter = url.searchParams.has('_rsc');
    return !isRSC && !isNextRouter;
  },
  new NetworkFirst({ cacheName: 'pages-cache' })
);

Web Push実装:2026年時点で一番詰まったポイント

Safariが Web Push に対応したのは嬉しかったけど、実装してみると細かいハマりポイントが多かった。「対応済み」と「まともに動く」の間にはかなりの距離がある。

VAPID設定とiOSの罠

// app/api/push/subscribe/route.ts
import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:your@email.com',
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
);

export async function POST(request: Request) {
  const subscription = await request.json();
  
  // DBに購読情報を保存
  await saveSubscription(subscription);
  
  return Response.json({ success: true });
}
// クライアントサイドの購読処理
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;
  
  // iOSでは permission request が user gesture 内である必要がある
  // ここを非同期で後回しにするとSafariで許可ダイアログが出ない
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.warn('通知許可が得られませんでした');
    return;
  }
  
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true, // iOSでは true 必須
    applicationServerKey: urlBase64ToUint8Array(
      process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
    ),
  });
  
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });
}

iOSの userVisibleOnly: true は外せない。これを false にするとエラーになる。また、通知の許可ダイアログはユーザーのタップイベントから同期的に呼び出さないとSafariでは無視される。これで1週間くらい悩んだ。ドキュメントにはさらっと書いてあるだけなのに、実害は大きい。

Push通知のパフォーマンス改善

sequenceDiagram
    participant U as ユーザー
    participant SW as Service Worker
    participant API as APIサーバー
    participant DB as Database

    API->>SW: Push通知送信
    SW->>SW: push イベント受信
    SW->>API: 通知詳細をフェッチ(任意)
    API->>DB: 最新データ取得
    DB-->>API: データ返却
    API-->>SW: 通知データ
    SW->>U: showNotification()
    U->>SW: notificationclick イベント
    SW->>SW: キャッシュ更新(バックグラウンド)
    SW->>U: 対象ページを開く

通知ペイロードに最小限のデータだけ含めて、クリック時に詳細を取得する設計にしたら、Push配信の失敗率が大幅に下がった。ペイロードが大きすぎると送信失敗することがあって、4KBの制限はわりとあっさり踏み抜くので注意が必要だ。


パフォーマンス計測と改善の結果

8ヶ月運用してのパフォーマンス変化を記録していたので共有する。数値はあくまでうちのケースなので参考値として見てほしいが、傾向としては再現性があると思っている。

xychart-beta
    title "PWA改善前後のCore Web Vitals(LCP)"
    x-axis ["導入時(10月)", "1ヶ月後", "3ヶ月後", "6ヶ月後", "8ヶ月後"]
    y-axis "LCP (ms)" 0 --> 5000
    bar [4200, 3600, 2800, 2100, 1850]
    line [4200, 3600, 2800, 2100, 1850]
xychart-beta
    title "オフライン対応後のユーザー離脱率(低回線環境)"
    x-axis ["PWA未対応", "Service Worker導入", "戦略最適化後"]
    y-axis "離脱率(%)" 0 --> 100
    bar [78, 45, 23]

LCPが 4200ms → 1850ms に改善できたのは、適切なプリキャッシュとキャッシュ戦略の組み合わせが効いている。低回線環境での離脱率も78% → 23%と劇的に改善した。個人的には離脱率の改善が一番わかりやすくて、ユーザーからの「重い」という声もほぼ聞かなくなった。

Next.js 15 × next-pwa の設定例

現在使っている next.config.ts の設定:

// next.config.ts
import withPWA from '@ducanh2912/next-pwa';

const nextConfig = {
  // App Router前提の設定
  experimental: {
    optimizePackageImports: ['@radix-ui/react-icons'],
  },
};

export default withPWA({
  dest: 'public',
  cacheOnFrontEndNav: true,
  aggressiveFrontEndNavCaching: true,
  reloadOnOnline: true,
  swcMinify: true,
  disable: process.env.NODE_ENV === 'development',
  workboxOptions: {
    disableDevLogs: true,
    // App RouterのRSCリクエストを除外
    exclude: [
      /\.map$/,
      /^.*\/api\/.*$/,
      /_next\/static\/chunks\/pages\/_app.*\.js$/,
    ],
    runtimeCaching: [
      {
        urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com/,
        handler: 'CacheFirst',
        options: {
          cacheName: 'google-fonts',
          expiration: {
            maxEntries: 10,
            maxAgeSeconds: 365 * 24 * 60 * 60,
          },
        },
      },
    ],
  },
})(nextConfig);

@ducanh2912/next-pwa を使っているのは、従来の next-pwa(serwist版)よりもApp Routerとの相性が良かったから。正直まだどちらが「正解」かは判断しにくいので、ここは好みが分かれると思う。両方試してみて、トラブルが少なかった方を選ぶくらいのスタンスでいいかもしれない。


PWAアーキテクチャの全体像

今のシステム構成をMermaidで整理するとこうなる。

flowchart TB
    subgraph Browser["ブラウザ"]
        subgraph App["Next.js 15 App"]
            UI["UI Components"]
            RSC["React Server Components"]
            UseHook["useServiceWorker Hook"]
        end
        subgraph SW["Service Worker (Workbox v8)"]
            Router["Route Matcher"]
            CF["CacheFirst Strategy"]
            NF["NetworkFirst Strategy"]
            SWR["StaleWhileRevalidate"]
            Cache[("Cache Storage")]
        end
    end

    subgraph Server["サーバーサイド"]
        API["Next.js API Routes"]
        Push["Web Push Service"]
        DB[("Database")]
    end

    UI -->|"ナビゲーション"| RSC
    UI <-->|"SW更新検知"| UseHook
    UseHook <-->|"postMessage"| SW
    RSC -->|"APIリクエスト"| Router
    Router --> CF
    Router --> NF
    Router --> SWR
    CF <--> Cache
    NF <--> Cache
    SWR <--> Cache
    NF <-->|"Fetch"| API
    API <--> DB
    Push -->|"Push Event"| SW
    SW -->|"showNotification"| Browser

まとめ

8ヶ月間 Next.js 15 × PWA を本番運用して得た知見を整理すると、以下の5点に集約される。

  1. skipWaiting() は気軽に使わない:複数タブ環境でのレースコンディションを引き起こす。UIからユーザーが明示的にトリガーする設計にするのが現実的

  2. キャッシュ戦略はリソース種別で明確に分ける:「全部NetworkFirst」は罠。API・静的アセット・画像でそれぞれ戦略を分けて、networkTimeoutSeconds も忘れずに設定する

  3. Next.js App Router の RSC リクエストはキャッシュ対象から外す:prefetchされるRSCペイロードをキャッシュするとデータが古いまま残る問題が起きる

  4. iOS向けのWeb Pushは userVisibleOnly: true 必須 + ユーザージェスチャー内で許可ダイアログを呼ぶ:これを守らないとSafariで無限に詰まる

  5. Isolated Web Apps(IWA)の本番投入はまだ早い:2026年現在はChromeの開発者フラグが必要。2027年以降を見据えて仕様を追いかけておく程度でOK

次のアクションとして取り組んでいるのは、Background Sync の改善(Workbox v8 の新APIを検証中)と、Badging API を使った未読件数表示。Background SyncはiOSの制限があって正直まだ検証中なので、うまくいったら別記事で書こうと思っている。

皆さんのチームでPWA運用してて「ここで詰まった」という話があればぜひ聞かせてほしい。Service Worker周りは情報が古かったり環境依存が多かったりで、実務知見の交換が一番効く分野だと思っているので。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事