はじめに
「オフラインでも使えるようにしてほしい」
IdeaSpool の開発中、自分自身がこの要望を強く感じました。
地下鉄に乗っているとき、ふとアイデアが浮かぶ。スマホを取り出してメモしようとしたら...圏外。「後でメモしよう」と思っても、電車を降りる頃には忘れている。
この問題を解決するために、Claude Code を使って PWA(Progressive Web App)のオフライン対応を実装しました。
ところが「オフラインでも動く」だけでは不十分でした。「オフライン中の操作をどこまで保持するか」という設計判断が必要になったのです。
この記事では、オフラインキューの設計で悩んだことと、最終的にたどり着いた「100件・7日・3回リトライ」という制限について共有します。
IdeaSpool とは
まず背景を説明させてください。
IdeaSpool は、アイデアをすばやくキャプチャして整理するツールです。思いついたアイデアをメモし、AI でタグ付けや分析を行い、プロジェクトにまとめていく。
このツールで最も重要なのは「キャプチャの速さ」です。アイデアは突然浮かびます。その瞬間にメモできないと、数分後には忘れてしまう。
だから「ネットワークに繋がらないから記録できない」という状況は、絶対に避けたかった。
なぜ PWA なのか
2026年1月19日の開発日記にはこう書きました:
PWA: モバイルでの爆速キャプチャを実現するため。ネイティブアプリ並みの体験を目指す
PWA を選んだ理由は3つあります。
1. インストールの手軽さ
ネイティブアプリはストアからダウンロードする必要があります。PWA は URL にアクセスするだけ。「ホーム画面に追加」を押せば、アプリのように使えます。
個人開発で Apple の審査を通すのは面倒ですし、Android と iOS の両方に対応するのも大変です。PWA なら1つのコードベースで済みます。
2. オフライン対応が可能
Service Worker を使えば、ネットワークがなくてもアプリを動かせます。キャッシュからリソースを読み込み、データは IndexedDB に保存する。
アイデアのキャプチャという用途では、これが決定的に重要でした。
3. Web の資産をそのまま活用
Next.js で作った Web アプリをそのまま PWA 化できます。新しいフレームワークを学ぶ必要がない。既存のコードをほぼそのまま使えます。
最初の実装:無制限にキューを貯める
Claude Code に依頼したオフライン対応の最初の実装は単純でした。
- オフライン中に行われた操作(作成・更新・削除)を IndexedDB に保存
- オンライン復帰時に、キューの操作をサーバーに送信
- 成功したらキューから削除
動きました。オフラインでアイデアをメモして、オンラインに戻ったら自動的にサーバーに同期される。
「完璧」と思った矢先、いくつかの問題に気づきました。
問題1:ストレージ容量
IndexedDB の容量はブラウザや端末によって異なります。無制限ではありません。
「1000件くらい大丈夫でしょ」と思うかもしれません。でも、各操作にはメタデータ(タイムスタンプ、操作タイプ、ペイロード)が付きます。アイデアの本文が長ければ、さらに容量を食います。
何も制限を設けないと、容量上限に達して新しいデータが保存できなくなるリスクがあります。
問題2:データの競合
もっと厄介な問題がありました。
1週間前にオフラインで編集したアイデアを、今更サーバーに同期するとどうなるか?
その間にサーバー側で同じアイデアが(別のデバイスから)更新されているかもしれません。古いデータで上書きしてしまうリスクがある。
「どちらが正しいか」を判断するロジックを入れることもできますが、複雑になります。そもそも1週間も前の操作を今更同期する必要があるのか?という疑問もありました。
問題3:同期処理の長時間化
キューが膨大だと、オンライン復帰時の同期処理が何分もかかります。
100件の操作を同期するなら、100回の API リクエストが発生します。それぞれに数百ミリ秒かかるとして、合計で数十秒〜数分。
その間、ユーザーはアプリを使えません。「同期中です...」という画面をずっと見せられることになる。これは UX として最悪です。
設計判断:3つの制限を設ける
これらの問題を解決するために、キューに制限を設けることにしました。
開発日記に悩みポイントを記録していました:
- PWA オフラインキューの設計
- どこまでキューに貯めるか
- → 100 件、7 日間、3 回リトライの制限を設定
この3つの制限について、それぞれ説明します。
制限1:最大100件
キューに貯められる操作は最大100件。超過した場合は古いものから削除します。
「100件」という数字は、以下の計算から決めました:
- 普通のユーザーが1日にキャプチャするアイデアの数: 5〜10件
- オフラインが続く最大日数: 7〜10日(旅行や災害時を想定)
- 10件 × 10日 = 100件
余裕を持って100件。これを超えるケースは稀でしょうし、超えたら古い操作は諦めてもらう。
制限2:7日間で失効
7日以上前の操作は自動削除します。
1週間以上オフラインが続くことは稀です。そこまで古い操作は、サーバー側のデータと競合するリスクが高い。
「もう諦めてください」という判断です。厳しいかもしれませんが、データの整合性を保つためには必要な制限だと考えました。
制限3:3回リトライ
同期に3回失敗した操作は、ユーザーに通知して手動解決を促します。
ネットワークの一時的な問題なら、3回以内に成功するはずです。3回失敗するということは、データそのものに問題がある可能性が高い。
たとえば、サーバー側で既に削除されたアイデアを更新しようとしている場合。いくらリトライしても成功しません。そういうケースはユーザーに通知して、「このデータは同期できませんでした」と伝える方が親切です。
実装:IndexedDB の基本
開発日記の学びにこう書きました:
IndexedDB はシンプルな用途なら生 API で十分(ライブラリ不要)
Dexie.js などのラッパーライブラリも検討しました。でも、オフラインキューの用途には生 API で十分でした。必要な操作は「追加」「取得」「削除」の3つだけ。
基本的なパターンを紹介します:
const DB_NAME = 'offline-queue';
const STORE_NAME = 'operations';
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
};
});
}
トランザクションを開始し、操作を実行し、完了を待つ。この流れを覚えれば、あとは応用です。
ライブラリを入れると依存関係が増えますし、バンドルサイズも大きくなります。シンプルな用途なら、生 API で書いた方がコントロールしやすいと感じました。
実装:オフライン検出
オフライン状態をリアルタイムで検出する必要があります。React 18 の useSyncExternalStore が便利でした。
import { useSyncExternalStore } from 'react';
export function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine,
() => true // SSR時はオンラインと仮定
);
}
このフックを使えば、コンポーネント内でオンライン/オフライン状態を取得できます。状態が変わると自動的に再レンダリングされます。
実装:オンライン復帰時の自動同期
online イベントで同期をトリガーします。ポイントは2つあります。
ポイント1:初回読み込み時のチェック
ユーザーがオフライン中にアプリを閉じ、オンライン復帰後に再度開いた場合を考えてください。
この場合、online イベントは発火しません。アプリが開いた時点で既にオンラインだからです。
だから、初回読み込み時にも「キューに未同期の操作があるか」をチェックし、あれば同期を実行する必要があります。
ポイント2:同期中のロック
同期処理が重複して実行されないよう、ロックを取る必要があります。
online イベントが連続して発火したり、ユーザーが手動で「今すぐ同期」ボタンを押したりするケースがあります。同期処理が二重に走ると、同じ操作が2回送信されてしまいます。
フラグ変数を使って、同期中は新しい同期を開始しないようにしました。
ユーザーへのフィードバック
オフライン状態をユーザーに伝えることも重要です。
画面の隅に「オフラインモード」と表示するだけで、ユーザーは「今の操作は後で同期される」と理解できます。何も表示しないと「保存されたのか?」と不安になります。
また、同期に失敗した操作がある場合は、その旨を通知します。「3件の操作が同期できませんでした。確認してください」のように。
黙って失敗を飲み込むと、ユーザーは「保存されたはずなのにデータがない」という状況に陥ります。これは最悪の UX です。
Next.js での PWA 対応
開発日記に書いたライブラリ選定:
Next.js の PWA 対応は
@ducanh2912/next-pwaが楽
有名な next-pwa というライブラリがあります。しかし、Next.js 16 の App Router との相性が悪いという問題がありました。
@ducanh2912/next-pwa はフォーク版で、App Router に正式対応しています。設定もシンプルで、next.config.ts に数行追加するだけで Service Worker が生成されます。
ライブラリ選定では、「Next.js のバージョン」と「App Router / Pages Router どちらを使っているか」を確認することが大切です。古い情報に惑わされないよう注意してください。
学んだこと
オフラインキューの実装で学んだのは、制限を設けることの重要性です。
「すべての操作を完璧に同期する」は理想ですが、現実的ではありません。ストレージ、競合、パフォーマンスの問題がある。
100件・7日・3回という制限は、ユーザー体験と技術的な安全性のバランスです。この数字に正解はありません。でも、根拠を持って決めることが大切です。
「なんとなく100件」ではなく、「1日10件 × 10日 = 100件」という計算がある。「なんとなく7日」ではなく、「データ競合のリスクを考慮して7日」という判断がある。
根拠があれば、後から見直すこともできます。「実際の利用データを見たら、1日20件の人もいた。制限を200件に上げよう」というように。
よくある疑問
この設計について、いくつか疑問があるかもしれません。
Q: 制限に引っかかったらデータは消えるの?
A: 古いデータから削除されます。ユーザーには「オフラインストレージがいっぱいです。古い未同期データが削除されました」と通知します。
突然消えるのではなく、警告を出すのがポイントです。
Q: 7日の制限は厳しすぎない?
A: 最初は30日にしようと思いました。でも、30日前の操作を今更同期するのは危険です。サーバー側のデータが大きく変わっている可能性が高い。
むしろ「7日以上オフラインが続くなら、一度オンラインに戻ってください」というのが正しい運用だと考えました。
Q: リトライ回数は3回で足りる?
A: ネットワークの一時的な問題なら、3回以内に成功します。3回失敗するのは、そもそも同期できないデータである可能性が高い。
回数を増やしても、成功する可能性は上がりません。むしろ、失敗し続けるデータがキューに溜まって、他の同期を遅らせるリスクがあります。
まとめ
| 設計判断 | 理由 |
|---|---|
| 最大100件 | ストレージ容量と同期パフォーマンス |
| 7日で失効 | データ競合のリスク軽減 |
| 3回リトライ | 永続的な失敗を防止 |
| 生 IndexedDB | シンプルな用途にはライブラリ不要 |
@ducanh2912/next-pwa | Next.js App Router 対応 |
PWA のオフライン対応は「完璧な同期」を目指すのではなく、「許容できる制限」を設計することだと学びました。
「オフラインでも使える」という機能は、ユーザーにとって大きな価値があります。でも、その実装には多くの設計判断が必要です。
この記事が、同じ課題に取り組む人の参考になれば幸いです。
関連記事
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。