背景と導入の理由
多くのWebアプリケーションは、インターネット接続が途切れないことを前提に作られています。しかし、ユーザーがエレベーターに乗った瞬間、飛行機に搭乗した瞬間、あるいは電波の届かないエリアを通過した瞬間に、その前提は崩れ去ります。ローディングスピナーは回り続け、保存されていないフォームの入力内容は消え、API呼び出しは不可解なネットワークエラーで失敗します。接続の切断を「まれな異常」として扱うフロントエンドを構築すると、脆弱なソフトウェアとなり、ユーザーにストレスを与えることになります。
オフラインファーストの開発手法は、この優先順位を根底から覆します。ブラウザのローカルストレージを「信頼できる唯一の情報源(Single Source of Truth)」として位置付けるのです。ユーザーが請求書を作成したり在庫を更新したりすると、変更は即座に端末のディスクへ書き込まれます。ネットワークの往復待ちは一切不要で、UIは瞬時に更新されます。同期処理はバックグラウンドで実行されます。接続が安定していれば、変更は数ミリ秒でサーバーに同期されます。もし接続が切断されても、変更内容は端末が再接続されるまでインデックス付きのローカルキューに保持されます。
ブラウザにはいくつかのローカルストレージの選択肢が用意されていますが、その性能や用途は大きく異なります。広く知られているlocalStorage APIはJavaScriptのメインスレッドをブロックし、オリジンあたりの最大容量が約5MBに制限されている上、生の文字列しか保存できません。そのため、大規模なデータセットの保存には適していません。
ブラウザ標準のIndexedDBは、これらの制限を解消します。非同期で動作し、トランザクションを処理でき、ギガバイト単位の構造化されたバイナリデータやJSONデータを保存可能です。Chromium系ブラウザでは、IndexedDBは利用可能なディスク容量の最大60%まで消費できるため、オフライン環境でも数万件規模のレコードを十分に保存できます。
ただし、素のIndexedDBには大きな難点があります。ネイティブAPIのコードが極めて冗長であるという点です。onsuccess、onerror、onupgradeneededといった従来のイベントコールバックを扱うと、日常的なCRUD操作であっても数十行もの防御的な定型コードが必要になります。また、捕捉されなかったトランザクションエラーによって処理がサイレントに失敗し、データベース接続が開いたまま放置されてしまうリスクもあります。
Dexie.jsは、この課題を解決します。Minifyおよびgzip後のサイズが約24KBと軽量なDexieは、IndexedDBの上にクリーンなPromiseベースの抽象化レイヤーを提供します。ネイティブエンジンのパフォーマンスを損なうことなく、直感的なクエリ操作、組み込みのトランザクションライフサイクル、手間のかからないスキーマのバージョニング機能を利用できます。
インストール
プロジェクトにDexieとクライアント側のUUIDジェネレーターを導入します。クライアント側でIDを生成することで、フロントエンド側で安定したエンティティキーを即座に割り当てることが可能になります。これにより、オフライン時の変更をPostgreSQLやMongoDBなどのバックエンドへ一括同期する際のキー衝突を防ぐことができます。
# npmを使用する場合
npm install dexie uuid
npm install --save-dev @types/uuid
# pnpmを使用する場合
pnpm add dexie uuid
pnpm add -D @types/uuid
Service WorkerのBackground Sync機能には、セキュアコンテキスト(HTTPS)が必須です。ローカル開発サーバーがlocalhostまたはHTTPS経由で稼働していることを確認してください。プレーンなHTTP環境では、ブラウザがService Workerの同期ハンドラーの登録を拒否します。
設定
今回のオフラインアーキテクチャは、2つの要素で成り立っています。ローカルレコードとOutboxキューを管理するIndexedDBスキーマ、そしてネットワーク接続の復旧時に保留中のリクエストを処理する同期エンジンです。
1. Dexieによるデータベーススキーマの定義
db.tsにデータベース設定ファイルを作成します。ここでは、キャッシュされたドメインレコードを保持するinventoryと、トランザクショナルOutboxとして機能するsyncQueueという2つのストアを定義します。
import Dexie, { Table } from 'dexie';
export interface InventoryItem {
id: string;
name: string;
sku: string;
quantity: number;
updatedAt: number;
}
export interface OutboxMutation {
id: string;
action: 'CREATE' | 'UPDATE' | 'DELETE';
endpoint: string;
payload: Record<string, unknown>;
timestamp: number;
retryCount: number;
}
export class AppDatabase extends Dexie {
inventory!: Table<InventoryItem, string>;
syncQueue!: Table<OutboxMutation, string>;
constructor() {
super('AppOfflineDatabase');
// Dexieでは、最初のインデックス対象フィールドが常にプライマリキーになります。
// 追加のインデックスプロパティはカンマ区切りのリストで指定します。
this.version(1).stores({
inventory: 'id, sku, updatedAt',
syncQueue: 'id, timestamp, retryCount'
});
}
}
export const db = new AppDatabase();
2. Outboxリポジトリパターン
ネットワークの切断はどの瞬間にも発生する可能性があります。UIの状態と同期キューの不整合を防ぐため、両方のデータ更新を単一のアトミックなDexieトランザクション内にカプセル化します。両方の書き込みが同時に成功するか、あるいはどちらも適用されないかのいずれかになります。
import { v4 as uuidv4 } from 'uuid';
import { db, InventoryItem, OutboxMutation } from './db';
import { triggerSyncProcess } from './syncService';
export async function saveInventoryItem(
itemData: Omit<InventoryItem, 'id' | 'updatedAt'>
): Promise<InventoryItem> {
const now = Date.now();
const newItem: InventoryItem = {
...itemData,
id: uuidv4(),
updatedAt: now
};
const mutation: OutboxMutation = {
id: uuidv4(),
action: 'CREATE',
endpoint: '/api/v1/inventory',
payload: newItem as unknown as Record<string, unknown>,
timestamp: now,
retryCount: 0
};
// アトミックなデュアルライト:ローカルUIテーブルを更新し、同期リクエストをエンキュー
await db.transaction('rw', [db.inventory, db.syncQueue], async () => {
await db.inventory.put(newItem);
await db.syncQueue.put(mutation);
});
// クライアントが現在オンラインであれば即座に同期を開始
triggerSyncProcess();
return newItem;
}
3. バックグラウンド同期エンジン
syncService.tsを作成します。このモジュールはOutboxを監視し、キューに入ったHTTPリクエストを時系列順に送信し、サーバー側で受領が確認されたアイテムをキューから削除します。
import { db } from './db';
let isSyncing = false;
const MAX_RETRIES = 5;
export async function triggerSyncProcess(): Promise<void> {
// 簡易接続チェック:オフライン時の無駄な処理ループを防止
if (!navigator.onLine || isSyncing) {
return;
}
isSyncing = true;
try {
// ミューテーションの順序を保持するため保留中レコードをFIFO順に取得
const pendingMutations = await db.syncQueue
.orderBy('timestamp')
.toArray();
for (const mutation of pendingMutations) {
try {
const response = await fetch(mutation.endpoint, {
method: mutation.action === 'CREATE' ? 'POST' : 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Idempotency-Key': mutation.id
},
body: JSON.stringify(mutation.payload)
});
if (response.ok) {
// サーバーが変更を受け入れたためローカルキューから削除
await db.syncQueue.delete(mutation.id);
} else if (response.status >= 400 && response.status < 500) {
// 4xx系エラーはペイロードの不正またはスキーマの不整合を示します。
// 恒久的なHead-of-Lineブロッキングを防ぐためレコードを破棄します。
console.error(`ミューテーション ${mutation.id} で回復不能なバリデーションエラー (${response.status}) が発生しました`);
await db.syncQueue.delete(mutation.id);
} else {
// 5xx系エラーは一時的なサーバー障害を意味するためリトライカウンタをインクリメント
await handleSyncFailure(mutation.id, mutation.retryCount);
}
} catch (networkError) {
// ソケットが切断されたか通信が途絶したためキュー処理を中断
console.warn('同期中にネットワークに接続できなくなりました。キュー処理を一時停止します。', networkError);
break;
}
}
} finally {
isSyncing = false;
}
}
async function handleSyncFailure(id: string, currentRetries: number): Promise<void> {
if (currentRetries + 1 >= MAX_RETRIES) {
console.error(`ミューテーション ${id} のリトライが5回失敗しました。隔離します。`);
await db.syncQueue.delete(id);
} else {
await db.syncQueue.update(id, { retryCount: currentRetries + 1 });
}
}
// ネットワークインターフェースが再接続されたら自動的に同期を再開
window.addEventListener('online', () => {
console.info('接続が復旧しました。Outboxキューをフラッシュしています...');
triggerSyncProcess();
});
4. Service Worker Background Syncの登録
ユーザーが別のページへ遷移したりタブを閉じたりすると、ブラウザはアクティブなJavaScript実行コンテキストを破棄します。SyncManager APIは、再試行の責務をブラウザのService Workerデーモンに直接委託することで、この問題を解決します。
ブラウザごとのサポート状況には注意が必要です。Chromium系ブラウザ(Chrome、Edge、Brave)はBackground Sync APIを完全にサポートしていますが、現時点でSafariとFirefoxは対応していません。クライアントコードではSyncManagerの機能検出を行い、非対応環境では標準のwindow onlineイベントリスナーへスムーズにフォールバックできるように実装する必要があります。
export async function registerBackgroundSyncTask(tag: string): Promise<void> {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
try {
// Outboxの再送処理をService Workerのバックグラウンドプロセスに委譲
await (registration as any).sync.register(tag);
return;
} catch (err) {
console.warn('バックグラウンド同期の登録に失敗しました。ページ内フォールバックを使用します。', err);
}
}
// Safari、Firefox、または非対応のWebView向けフォールバック
triggerSyncProcess();
}
動作検証とモニタリング
擬似的なパケット損失環境下でのテストやローカルストレージ消費量の確認を行うまでは、オフラインパイプラインが正常に機能していると過信してはいけません。
1. DevToolsでのIndexedDBとネットワーク状態の確認
- Chrome DevToolsを開き(
F12またはCmd+Option+I)、Applicationタブを選択します。 - Storage > IndexedDB > AppOfflineDatabaseを展開し、
inventoryとsyncQueueの両方のテーブルが存在することを確認します。 - Networkタブに切り替え、スロットリングの設定をNo throttlingからOfflineに変更します。
- アプリ内で新しい在庫アイテムを保存します。レコードはUIコンポーネントに即座に反映され、IndexedDBの両方のテーブルに書き込まれます。不通のネットワークへリクエストが無駄撃ちされることがないため、DevToolsには失敗したネットワークリクエストが一切表示されません。
- NetworkのプリセットをOnline(またはFast 4G)に戻します。NetworkログでHTTP
POSTリクエストがステータス200または201で成功し、500ミリ秒以内にsyncQueueテーブルがクリアされる様子を確認します。
2. バックグラウンド同期イベントのシミュレーション
Service Workerをテストするために、物理的なWi-Fiスイッチを切り替える必要はありません。DevToolsのApplication > Service Workersへ移動し、Sync入力フィールドを見つけて登録済みの同期タグ(例: inventory-sync)を入力したら、Trigger Syncをクリックします。DevToolsがWorkerスクリプトに対して直接、擬似的な同期イベントを発火させます。
3. ストレージ容量とテレメトリの監査
空き容量の少ないモバイル端末では、一時的なIndexedDBパーティションが予告なく破棄されることがあります。容量の使用状況を確認し、ブラウザに対して永続ストレージの許可をリクエストすることで、アプリのデータを保護できます。
export async function auditStorageHealth(): Promise<void> {
if (!navigator.storage?.estimate) {
return;
}
const { usage, quota } = await navigator.storage.estimate();
const usedMb = ((usage ?? 0) / (1024 * 1024)).toFixed(2);
const quotaMb = ((quota ?? 0) / (1024 * 1024)).toFixed(2);
const percentUsed = usage && quota ? ((usage / quota) * 100).toFixed(1) : '0';
console.info(`ローカルストレージ: ${quotaMb} MB中 ${usedMb} MB使用中 (${percentUsed}%)。`);
// ディスク容量逼迫時にブラウザがIndexedDBキャッシュを破棄しないよう永続ストレージをリクエスト
if (navigator.storage.persist) {
const isPersistent = await navigator.storage.persist();
console.info(`永続ストレージの許可状態: ${isPersistent}`);
}
}
Dexie.jsとOutbox同期Workerを組み合わせることで、不安定なネットワーク環境は致命的なエラーではなく、単なるバックグラウンドの処理詳細へと変わります。UIは常に軽快に動作し、データの変更はブラウザを再起動しても維持され、バックエンドには重複のないクリーンなデータが常に届けられます。
