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の対応状況
| API | Chrome | Firefox | Safari | 備考 |
|---|---|---|---|---|
| Service Worker | ✅ | ✅ | ✅ | iOS 17.4以降はサードパーティブラウザでも動作 |
| Web Push | ✅ | ✅ | ✅ | Safari 16.4+対応済み |
| Web App Manifest | ✅ | ✅ | ✅ | display: standalone 全般OK |
| Badging API | ✅ | ✅ | △ | macOS 14以降は対応 |
| File System Access | ✅ | △ | ❌ | Safariは非対応のまま |
| Isolated Web Apps (IWA) | ✅(Dev) | ❌ | ❌ | Chrome 開発者向けフラグ、本番未展開 |
| Background Sync | ✅ | ✅ | ❌ | iOSは依然制限あり |
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点に集約される。
-
skipWaiting()は気軽に使わない:複数タブ環境でのレースコンディションを引き起こす。UIからユーザーが明示的にトリガーする設計にするのが現実的 -
キャッシュ戦略はリソース種別で明確に分ける:「全部NetworkFirst」は罠。API・静的アセット・画像でそれぞれ戦略を分けて、
networkTimeoutSecondsも忘れずに設定する -
Next.js App Router の RSC リクエストはキャッシュ対象から外す:prefetchされるRSCペイロードをキャッシュするとデータが古いまま残る問題が起きる
-
iOS向けのWeb Pushは
userVisibleOnly: true必須 + ユーザージェスチャー内で許可ダイアログを呼ぶ:これを守らないとSafariで無限に詰まる -
Isolated Web Apps(IWA)の本番投入はまだ早い:2026年現在はChromeの開発者フラグが必要。2027年以降を見据えて仕様を追いかけておく程度でOK
次のアクションとして取り組んでいるのは、Background Sync の改善(Workbox v8 の新APIを検証中)と、Badging API を使った未読件数表示。Background SyncはiOSの制限があって正直まだ検証中なので、うまくいったら別記事で書こうと思っている。
皆さんのチームでPWA運用してて「ここで詰まった」という話があればぜひ聞かせてほしい。Service Worker周りは情報が古かったり環境依存が多かったりで、実務知見の交換が一番効く分野だと思っているので。