web-pwa-offline-first について
オフラインファーストの Web アプリを開発する人向けに、ローカル保存、変更キュー、同期のパターンをまとめています。IndexedDB の運用、競合解決、同期状態の表示も扱います。
主な機能
- 01IndexedDB をローカルの正本として使用
- 02再接続後に処理する変更キュー
- 03削除を墓石レコードで記録
- 04競合解決方法の選択
- 05接続検出と同期状態の表示
活用シーン
- 01オフラインでの読み書きの継続
- 02再接続後の保留中変更の再試行
- 03複数クライアント間の編集競合への対応
- 04ローカル保存と同期の進捗表示
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-pwa-offline-firstインストール後、Claude Code で/web-pwa-offline-firstを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.yamlreference.mdexamples/core.mdindexeddb.mdsync.md詳細
オフラインファーストアプリケーションのパターン
クイックガイド: 読み書きはまずローカルデータベースに対して行い、その後ネットワークとの同期が追いつきます。IndexedDB がストアとなり、Dexie(リアクティブなクエリに対応し、サイズは大きめ)や idb(薄型、約1.2KB)のようなラッパーを通じて利用します。同期可能な各レコードには
_syncStatus、_lastModified、_localVersionを持たせ、キューが未処理の項目を把握できるようにします。削除は削除ではなく、常にトゥームストーンとして記録します。そうしないと、遅れて実行された同期によってレコードが復活します。Background Sync API は Chromium のみで利用できるため、onlineリスナーを基本の仕組みとし、バックグラウンド同期は最適化として使います。
詳細なリソース:
- examples/core.md — 同期可能なエンティティ、リポジトリ、バックオフ付き同期キュー、接続状態の検出、楽観的更新、ステータスと変更のためのフック
- examples/indexeddb.md — Dexie のスキーマとマイグレーション、idb の代替案、複数タブの連携、クォータ管理
- examples/sync.md — 最終書き込み優先、フィールド単位のマージ、競合 UI、バージョンベクトル、差分カーソル、バックグラウンド同期、プル・プッシュ、ステータスインジケーター
- reference.md — ストレージと競合解決戦略の選択、症状別のトラブルシューティング、パフォーマンスとセキュリティに関する注意事項
該当するパターン
- 読み取り中心で、書き込みには接続が必要 — 読み取り用にローカルコピーを保持し、オンライン時に更新します。キューも競合解決もありません。パターン1、4、6がすべてです。
- オフラインでの完全な CRUD — 変更をキューに登録し、競合が発生するため、ここにあるすべてのパターンが該当します。examples/core.mdから始め、examples/sync.mdから解決戦略を選んでください。
<critical_requirements>
オフラインファーストのコードを書く前に
ローカルデータベースを信頼できる唯一の情報源として扱います。 すべての読み取りはローカルデータベースから行い、すべての書き込みは送信前にまずローカルデータベースに反映します。これにより、接続状態にかかわらず UI が即座に応答できます。
同期可能なすべてのレコードに _syncStatus、_lastModified、_localVersion を持たせます。 これらがなければ、未処理の項目を確認する方法も、競合と新しい書き込みを区別する方法もありません。
削除は _deletedAt トゥームストーンを書き込んで行います。 行を削除すると同期対象が残らず、次のプルでレコードが復活してしまいます。
すべての変更をキューに登録し、再接続時に指数バックオフとジッターを使ってキューを処理します。 そうしないと、一時的な 502 によって書き込みが永久に失われる可能性があります。また、多数のクライアントが同時にリトライすると、短時間の障害が長引く原因になります。
IndexedDB トランザクション内では、他の await を使わないようにします。 保留中のリクエストがない状態で制御がイベントループに戻ると、トランザクションは閉じます。そのため、トランザクション中に fetch を await すると TRANSACTION_INACTIVE_ERR が発生します。先に fetch を行い、その後でトランザクションを開いてください。
</critical_requirements>
自動検出: IndexedDB、indexedDB.open、IDBDatabase、IDBObjectStore、openDB、DBSchema、Dexie、useLiveQuery、dexie-react-hooks、idb-keyval、sync queue、tombstone、_syncStatus、_lastModified、last-write-wins、version vector、offline-first、local-first、navigator.onLine、navigator.storage.persist、BroadcastChannel、QuotaExceededError
対象範囲:
- 再読み込みや切断後も維持されるローカルストアの選択と設計
- オフラインでユーザーが編集するレコードの同期メタデータのモデル化
- 変更のキュー登録、リトライ、未処理項目の報告
- ネットワークインターフェースではなく、実際の接続状態の検出
- 最終書き込み優先からバージョンベクトルまでの、同時編集の解決
- ローカルに保存済みの内容とサーバーに到達した内容をユーザーに表示
別の項目で扱うもの:
- ネットワークリクエストのインターセプトや HTTP レスポンスキャッシュのバージョン管理 — レスポンスキャッシュはサーバーの応答を保存して期限切れにします。このスキルでは、ユーザーが作成したレコードを扱います
- ネットワークがなくてもアプリを起動できるようにする、アプリケーションシェルの事前キャッシュ
- 収束する複製データ型を必要とし、キューとマージには適さない、文字単位の共同編集
## 設計思想
ネットワークは機能を高めるものです。データベースはローカルストレージであり、サーバーは同期して整合させるピアです。これは、ローカルストレージが真のデータのキャッシュとなる一般的な構成を逆転させたものです。
ここから2つのことが導かれます。書き込みがリクエストの完了を待たないため、UI はネットワーク速度ではなくディスク速度で応答します。また、すべての書き込みは異議を唱えられる可能性のある主張になります。そのため、同期メタデータは後付けではなく基盤となります。バージョンのないレコードは、どのマージ処理でも判断できません。
User action
│
Local database ←── the single source of truth
│
UI updates immediately
│
Sync queue (background)
│
Server, when reachable
│
Conflict resolution, if the record moved on both sides
│
Local database updated
ユーザーに残されるのは、信頼できる状態を確認することです。変更が保存され、キューに追加され、最終的に反映されたことを確認できる必要があります。同期状態はデバッグ用の補助情報ではなく、プロダクトの表側に示す情報です。
基本パターン
パターン 1:同期可能なエンティティ
ほかのすべてのパターンは、これらのフィールドを参照します。業務データと同期メタデータは分離し、マージ処理でメタデータ全体をまとめて除外できるよう、メタデータにはプレフィックスを付けます。
interface SyncableEntity {
id: string;
_syncStatus: "synced" | "pending" | "conflicted";
_lastModified: number;
_serverTimestamp?: number;
_localVersion: string;
_serverVersion?: string;
_deletedAt?: number; // tombstone
}
完全なコード:examples/core.md
パターン 2:リポジトリ
コレクションへのアクセスを一元化し、書き込みが2つの操作からなることを呼び出し元が覚えておく必要がないようにします。読み取りでは論理削除された項目を除外します。書き込みではメタデータを付与し、ローカルに保存してからキューに追加します。
interface DataRepository<T extends SyncableEntity> {
get(id: string): Promise<T | null>; // null for a tombstone
getAll(): Promise<T[]>;
save(item: T): Promise<void>; // local write, then enqueue
delete(id: string): Promise<void>; // tombstone, then enqueue
getPendingCount(): Promise<number>;
}
完全なコード:examples/core.md
パターン 3:バックオフ付き同期キュー
上限を設けた指数関数的な遅延にジッターを加え、再接続したクライアントが同時に到着しないようにします。
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 30_000;
const JITTER_FACTOR = 0.5;
function calculateBackoff(attempt: number): number {
const delay = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
const jitter = delay * JITTER_FACTOR * (Math.random() * 2 - 1);
return Math.floor(delay + jitter);
}
完全なコード:examples/core.md — 再試行回数の上限、タイムスタンプ順の並べ替え、デッドレター処理
パターン 4:接続状態の検出
navigator.onLine が示すのはネットワークインターフェースの有無です。キャプティブポータルの背後や、上流への接続がないルーターにつながっている場合でも、値は true になります。リクエストを送って確認してください。
async function checkConnectivity(): Promise<boolean> {
if (!navigator.onLine) return false; // cheap negative, trustworthy
try {
const response = await fetch("/api/health", {
method: "HEAD",
cache: "no-store",
});
return response.ok;
} catch {
return false;
}
}
完全なコード:examples/core.md — レイテンシの測定と「低速」状態
パターン 5:オプティミスティック更新とロールバック
書き込み前に以前の値を取得して、元に戻す処理を返します。これにより、呼び出し元のエラー処理は値を再構築せず、1回の呼び出しで済みます。
async function applyOptimistically<T>(id: string, next: T) {
const previous = (await localDb.get(id)) ?? null;
await localDb.put(next);
return async function rollback() {
if (previous) await localDb.put(previous);
else await localDb.delete(id);
};
}
完全なコード:examples/core.md
パターン 6:接続状態を考慮したデータ取得
データの取得元もデータと合わせて返します。これにより、古いデータを最新のものとして黙って表示するのではなく、UI で「保存済みデータを表示中」と示せます。
interface FetchResult<T> {
data: T;
source: "network" | "cache";
timestamp: number;
}
// try network with a timeout, cache the success, fall back to cache on any failure
const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
完全なコード:examples/core.md
パターン 7:競合の解決
コストの低い順に3つの戦略があります。最も簡単な方法ではなく、フィールドの意味に基づいて選んでください。
- 最終書き込み優先 — 最も新しいタイムスタンプの値をレコードに採用します。互いに独立した値には適していますが、2人が異なるフィールドを編集した場合には適しません。examples/sync.md パターン 17
- フィールド単位のマージ — 各フィールドを最後に共有された状態と比較し、両側で同じフィールドが変更された場合にのみ競合とします。examples/sync.md パターン 18
- バージョンベクトル — クライアントごとのカウンターを使い、時計を信頼せずに、同時編集と順次編集を区別します。examples/sync.md パターン 20
どちらの側も破棄できない場合は、競合を示してユーザーに選択してもらいます:examples/sync.md パターン 19。
<red_flags>
注意すべき問題
実行時に問題が発生します:
- IndexedDB トランザクション内で
await fetch(...)やawait new Promise(setTimeout)を実行する — トランザクションはすでに終了しており、次の操作でTRANSACTION_INACTIVE_ERRが発生します — 非同期処理を先に行い、その後で短いトランザクションを開いてください - 同期されていないレコードを完全に削除する — 次回のプルで復活します —
_deletedAtを書き込み、同期が確認された後にトゥームストーンを一括削除してください - クォータを確認せずに書き込む — 警告なしに保存が失敗し、
QuotaExceededErrorが発生します —navigator.storage.estimate()を確認し、書き込み前にデータを削除してください - 上限のないキュー — オフライン状態が長時間続くとストレージが枯渇し、キューを一度にすべて処理するとメモリが枯渇します — キューに上限を設け、分割して処理してください
- UI を更新する前にサーバーを待つ — アプリは余計な手順を伴うオンラインファーストになります — まずローカルに保存し、その後キューに追加してください
意外な挙動:
navigator.onLine === trueはインターフェースが存在することを意味するだけで、何かに接続できることを示すわけではありません- Safari ではユーザー操作がない場合、スクリプトから書き込み可能なストレージの上限は7日間で、IndexedDB も対象です。
navigator.storage.persist()が役立ち、アプリをインストールするとさらに効果があります navigator.storage.estimate()にはセキュアコンテキストが必要です。そうでない場合は{ usage: 0, quota: 0 }が返されます- Background Sync は Chromium でのみ利用できるため、それ以外では
onlineリスナーが処理を担う必要があります - IndexedDB にはブール値のインデックス型がありません — 複合クエリでは
[userId+completed]に対して[userId, 1]を照合します - Dexie の
useLiveQueryは読み込み中にnullではなくundefinedを返すため、=== undefinedで読み込み状態を確認します - 2つのタブは調整なしに1つのデータベースへ書き込みます。両者の合意には
BroadcastChannelを使用します(examples/indexeddb.md パターン15) - デバイス間でタイムスタンプを比較してわかるのは差異であり、因果関係ではありません — クロックのずれにより、順次行われた編集が同時に見えたり、その逆になったりします