Next.js 15でPWA本番運用8ヶ月、Service Workerで痛い目を見た話
App Store審査が羨ましいと思ったことは一度もない。Next.js 15でPWA化して8ヶ月、最初の3ヶ月は想定外トラブル続きだった実録と、そこから得た設計知見をまとめました。
先日、チームのSlackに「App Storeへの申請、やっぱり面倒すぎる」というメッセージが流れてきた。モバイルアプリチームが新機能リリースのたびに審査待ちで1〜2週間ロスしているという話だった。それを聞いて「PWAにしてよかった」と改めて思った。うちのチームはおよそ8ヶ月前にNext.js 15ベースのWebアプリをPWA化して本番運用しているんだけど、正直最初の3ヶ月は想定外のトラブル続きで、かなり痛い目を見た。
「PWAとはなんぞや」的な基礎説明は省く。この記事で書くのは、実際に本番で踏んだ落とし穴と、2026年時点で使えるAPIや設計パターンの話だ。似たような構成を検討してるエンジニアの参考になれば。
なお、Next.js 15やReact 19まわりの基本的な話はReact 19・Next.js 15の新機能を実装例で完全解説に詳しく書いたので、そちらも読んでみてほしい。
構成の全体像と、なぜnext-pwaを捨てたか
まず、うちの構成を整理しておく。
flowchart TB
subgraph Client["ブラウザ / モバイル"]
A[Next.js 15 App Router]
B[Service Worker]
C[IndexedDB Cache]
A -- 登録 --> B
B -- キャッシュ読み書き --> C
end
subgraph Server["サーバー"]
D[Next.js API Routes]
E[Web Push Service]
F[VAPID Key管理]
D --- E
E --- F
end
subgraph External["外部"]
G[FCM / APNs Bridge]
H[Cloudflare CDN]
end
A -- fetch --> D
B -- push受信 --> G
E --> G
H -- 静的配信 --> A
もともとnext-pwa(ducanh2912/next-pwa)を使っていたんだけど、Next.js 15のApp Routerへの完全対応が遅くて、2025年末に自前のService Worker実装に切り替えた。serwistライブラリ経由で使う方法もあるが、うちのケースではService Workerの更新戦略を細かくコントロールしたかったので、TypeScriptで直接書くことにした。
これが正直「思ったより大変だった」の第一歩だった。
Service Workerの更新フロー、最初の実装が盛大に間違ってた
一番ハマったのがこれ。Service Workerの更新は仕様上こういう流れになる。
sequenceDiagram
participant User as ユーザー
participant App as Next.js App
participant SW_Old as 旧SW
participant SW_New as 新SW
User->>App: ページを開く
App->>SW_Old: 既存SWが制御中
App->>SW_New: 新SWをインストール(waiting状態)
Note over SW_New: waiting... 旧SWが全タブで解放されるまで待機
User->>App: タブを全部閉じて再度開く
SW_New->>App: 新SWがactivateされて制御開始
最初の実装では「新しいバージョンをデプロイしたら自動で当たる」と思い込んでいた。実際はwaiting状態で止まり続け、ユーザーが全タブを閉じないと更新が適用されない。Chromeのデベロッパーツールで確認して初めて気づいた。なんなら数日間、ユーザーに古いバージョンを使わせていたことになる。
対策として実装したのが「更新があったらUIでトーストを出して、ユーザーにskipWaiting()を促す」パターン。
// public/sw.ts(TypeScriptで書いてビルド時にトランスパイル)
declare const self: ServiceWorkerGlobalScope;
const CACHE_NAME = 'app-cache-v3';
const STATIC_ASSETS = [
'/',
'/offline',
'/_next/static/css/main.css',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
// 新SWインストール完了時点ではskipWaitingしない
// UIからのメッセージを待つ
});
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
});
// hooks/useServiceWorker.ts
import { useEffect, useState } from 'react';
export function useServiceWorker() {
const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null);
const [updateAvailable, setUpdateAvailable] = useState(false);
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('/sw.js').then((registration) => {
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
setWaitingWorker(newWorker);
setUpdateAvailable(true);
}
});
});
});
}, []);
const applyUpdate = () => {
waitingWorker?.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
};
return { updateAvailable, applyUpdate };
}
これで「新しいバージョンがあります。更新しますか?」というトーストをユーザーに出せるようになった。地味に使い心地が全然違う。ユーザーからの「アプリが古いまま」という問い合わせがほぼゼロになったのは、個人的にはかなり嬉しかった。
キャッシュ戦略の設計、3パターンを使い分けてる
キャッシュ戦略を全部「Network First」にするのは罠で、オフライン対応が目的じゃないページまでキャッシュロジックが走ってパフォーマンスを食う。うちは以下の3パターンを用途別に使い分けている。
| コンテンツ種別 | 戦略 | TTL | 理由 |
|---|---|---|---|
| 静的アセット(CSS/JS/画像) | Cache First | ビルドハッシュで永続 | 変更はURLで管理されるため |
| APIレスポンス(ユーザーデータ) | Network First | 5分 | 鮮度が重要 |
| 画面(HTML) | Stale While Revalidate | 1時間 | 初回表示速度とコンテンツ鮮度のバランス |
| フォーム送信など変更系 | Network Only | なし | キャッシュさせてはいけない |
SWのfetchハンドラはこんな感じ。
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// POSTなど変更系はNetwork Only
if (request.method !== 'GET') return;
// 静的アセットはCache First
if (url.pathname.startsWith('/_next/static/')) {
event.respondWith(cacheFirst(request));
return;
}
// APIはNetwork First(5分TTL)
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, 5 * 60));
return;
}
// ページはStale While Revalidate
event.respondWith(staleWhileRevalidate(request));
});
async function cacheFirst(request: Request): Promise<Response> {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
}
async function networkFirst(request: Request, ttlSeconds: number): Promise<Response> {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
// TTLをレスポンスヘッダーに埋め込んで管理する
const responseToCache = new Response(response.body, {
headers: {
...Object.fromEntries(response.headers.entries()),
'sw-cache-timestamp': Date.now().toString(),
'sw-cache-ttl': ttlSeconds.toString(),
},
});
cache.put(request, responseToCache);
return response;
} catch {
const cached = await caches.match(request);
return cached ?? new Response('Offline', { status: 503 });
}
}
async function staleWhileRevalidate(request: Request): Promise<Response> {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
return cached ?? fetchPromise;
}
このTTL管理をカスタムヘッダーで実装するのは少し泥臭いんだけど、Cache Storageには標準でTTLを設定する仕組みがないので、現状はこれで運用している。正直もう少しスマートな方法があれば知りたいところ(みなさんはどうしてます?)。
Web PushとBadging APIで体験がネイティブ寄りになった
PWAの目玉機能のひとつが通知まわり。2026年現在、Chrome・Edge・Firefoxだけでなく、Safari 18.x(iOS/macOS)でもWeb Pushが使えるようになってかなり状況が変わった。個人的にはこれが一番嬉しいアップデートだった。
実装手順はざっくりこう。
// lib/webpush.ts(サーバーサイド)
import webpush from 'web-push';
const vapidKeys = {
publicKey: process.env.VAPID_PUBLIC_KEY!,
privateKey: process.env.VAPID_PRIVATE_KEY!,
};
webpush.setVapidDetails(
'mailto:team@example.com',
vapidKeys.publicKey,
vapidKeys.privateKey
);
export async function sendPushNotification(
subscription: PushSubscription,
payload: { title: string; body: string; url?: string }
) {
try {
await webpush.sendNotification(
subscription as webpush.PushSubscription,
JSON.stringify(payload)
);
} catch (error: unknown) {
// 410 Gone = サブスクリプション無効。DBから削除する
if (error instanceof Error && 'statusCode' in error && (error as { statusCode: number }).statusCode === 410) {
await deleteSubscription(subscription.endpoint);
}
throw error;
}
}
// SW側のpushハンドラ
self.addEventListener('push', (event) => {
const data = event.data?.json() as {
title: string;
body: string;
url?: string;
};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
data: { url: data.url ?? '/' },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = (event.notification.data as { url: string }).url;
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
const existingWindow = clientList.find((c) => c.url === url);
if (existingWindow) return existingWindow.focus();
return clients.openWindow(url);
})
);
});
それに加えて、Badging APIが地味に便利だった。アプリアイコンに未読バッジを表示できる。
// 未読件数をバッジで表示
export async function updateAppBadge(count: number) {
if ('setAppBadge' in navigator) {
if (count > 0) {
await navigator.setAppBadge(count);
} else {
await navigator.clearAppBadge();
}
}
}
ネイティブアプリのバッジと見た目が一緒になるので、ユーザーの習熟コストがほぼゼロ。これはマジで助かった。「アプリっぽくない」という初期ユーザーの声がこの辺りの実装後にほとんど聞かれなくなった。
Install UXまわりはPWA完全ガイド2026|Isolated Web Apps・Web Push・Badging APIでネイティブ超えでより詳しく書いているので参考にしてほしい。
8ヶ月運用して見えたパフォーマンスと落とし穴
実際にどう変わったかをデータで見ると、こんな感じだった。
xychart-beta
title "PWA化前後のパフォーマンス指標比較"
x-axis ["FCP(ms)", "LCP(ms)", "TTI(ms)", "リピート起動(ms)"]
y-axis "時間 (ms)" 0 --> 4000
bar [2100, 3800, 4100, 2600]
bar [900, 1400, 1800, 380]
左のバーが導入前、右が導入後。特にリピートユーザーの起動時間は2600ms→380msと劇的に改善した。Service WorkerによるCache Firstの恩恵がほぼそのまま数字に出ている。FCPとLCPも半分以下になっていて、これは正直ここまで改善するとは思っていなかった。
一方で、落とし穴もしっかり踏んだ。
キャッシュ汚染問題:開発中にバグのあるAPIレスポンスをキャッシュしてしまい、ユーザーの一部がずっとエラー画面を見続けるという事故が起きた。Network Firstのフォールバックロジックに問題があったのが原因だった。今はキャッシュに乗せるAPIレスポンスはステータス200かつContent-Typeがapplication/jsonのものだけに絞っている。
iOS SafariのService Workerライフタイム問題:iOSでは一定期間バックグラウンドに回るとService Workerが破棄される。2026年のSafari 18.xでもこの挙動は完全には解消されていない(改善はされてはいる)。プッシュ通知の受信漏れが発生することがあり、まだ完全な解決策がない。正直、iOS向けにはWebViewラッパーの方が安定しているケースもあると思っている。ここは好みが分かれるところだと思う。
manifest.jsonのアイコン管理:アイコンのサイズ要件が2026年現在も増え続けていて(Apple向け・Android向け・Windows向け)、next.config.tsで自動生成する仕組みを入れないと手動管理がすぐ破綻する。
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/sw.js',
headers: [
{ key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' },
{ key: 'Service-Worker-Allowed', value: '/' },
],
},
];
},
};
export default nextConfig;
Service Workerのキャッシュヘッダーはno-cache必須。ブラウザがSWファイル自体をキャッシュすると更新が永遠に当たらない。これを最初に知らなくてしばらくハマった。地味だけど、ここを間違えると本当に詰む。
アプリのセキュリティまわりについては、PWAもWebアプリである以上XSSやCSRFの対策は必須。OWASP Top 10 2024対策|脆弱性10項目の実装方法と企業の守り方も合わせて読んでおくことをおすすめする。
まとめ
8ヶ月のPWA本番運用で学んだことを整理すると、こうなる。
- Service Workerの更新フロー設計が最重要。
skipWaiting()はUIと連動させて、ユーザーのコントロールのもとで実行する - キャッシュ戦略は一律にせず、コンテンツ種別ごとに分ける。特に変更系APIはNetwork Only必須
- Web PushはSafari対応でほぼクロスブラウザ化。Badging APIとセットで実装するとUXが一気にネイティブ寄りになる
- iOSのService Workerライフタイムは2026年時点でも完全解決していない。プッシュ通知の信頼性はAndroidに比べてまだ落ちる
- SW自体のキャッシュヘッダーは
no-cacheを必ず設定する。ここが間違っていると更新が永遠に当たらない
次のアクションとしては、Isolated Web Apps(IWA)の検証を始めようと思っている。まだChrome限定の機能だけど、ローカルファイルアクセスやより強力なAPI権限が使えるようになる話で、ネイティブアプリとの差をさらに埋める可能性がある。正直まだ検証中で本番投入できる状態じゃないけど、1〜2年後には本命になるかもしれないと個人的には見ている。
PWA化を検討していて「どこから始めればいいか」という方は、まずService Workerの更新フロー設計だけに集中することをおすすめする。そこが一番トラブルが多くて、かつ一番体験に直結する部分だから。