メインコンテンツへスキップ
記事一覧に戻る
programmingadalab focus 開発記 (2/5)

オフライン同期は Outbox パターンで作る — Dexie + Cloudflare D1 実装解説

PWA のオフライン編集をサーバーと自動同期させる Outbox パターンの実装を解説。IndexedDB を Single Source of Truth にした設計、Last-Write-Wins による競合解決、Background Sync までの道のりを adalab focus の実例で紹介します。

2026-06-138 min readAdabana Saki
-
PWAIndexedDBDexieオフライン同期Outbox パターンCloudflare D1TypeScriptadalab focus
シリーズ中級者〜上級者

adalab focus 開発記

学習管理 PWA「adalab focus」の開発で得た知見を解説。ローカルファースト設計、オフライン同期、サーバーレス認証、タイマーの状態設計、ブラウザ拡張との連携まで

進捗2 / 5

オフライン同期は Outbox パターンで作る — Dexie + Cloudflare D1 実装解説

前回の記事では、学習管理アプリ adalab focus の全体構成を紹介しました。今回はその心臓部、オフライン編集を壊さずにサーバーへ同期する仕組みの話です。

「オフライン対応」と聞くと難しそうですが、構造を 1 つ決めてしまえば後は機械的に作れます。それが Outbox パターンです。

素朴な実装はどこで壊れるか

まず、やりがちな失敗から:

TypeScript

これはオフラインで即死しますし、オンラインでも電波が弱いと「ボタンを押したのに何も起きない」アプリになります。

「じゃあ失敗したらローカルに貯めて後で送ろう」と継ぎ足すと、今度は成功パスと失敗パスでデータの流れが 2 本になり、エッジケースの泥沼が始まります。

Outbox パターン:流れを 1 本にする

Outbox パターンの本質は、オンラインでもオフラインでも全く同じコードパスを通すことです。

Text

オフラインかどうかは ③ のタイミングが変わるだけで、①② は常に同じです。分岐がないのでバグが入る隙間もありません。

実装:outbox テーブル

Dexie のスキーマに送信待ちキューを 1 テーブル足します:

TypeScript

書き込み系の関数は全部この 2 行セットで終わります:

TypeScript

flush:キューを順番に流す

flush は「キューの先頭から順にサーバーへ送り、成功したら消す」だけです。ただし多重実行の防止だけは必須です(オンライン復帰イベントと定期実行が重なるため):

TypeScript

重要なのは失敗時に何も特別なことをしないことです。キューに残っているものは「まだ送れていないもの」であり、次の機会に先頭から再試行されます。リトライロジックを書かないことがリトライロジックになっています。

競合解決:Last-Write-Wins で十分

PC とスマホで同じタスクを編集したら?という問題には、全レコードに updated_at(ミリ秒タイムスタンプ)を持たせて Last-Write-Wins(後勝ち) で解決しています。

SQL

CRDT のような本格的な競合解決も検討しましたが、採用しませんでした。理由は単純で、学習管理アプリは 1 ユーザー = 1 人だからです。自分が PC とスマホで同じタスクのタイトルを同時に書き換える状況はまず起きず、起きたとしても「最後に編集した方が残る」は直感に反しません。

Text

道具は問題のサイズに合わせるのが大事です。

削除は「物理削除しない」

同期があるアプリでは、レコードの物理削除は事故のもとです。端末 A で削除 → 端末 B がオフライン編集 → B が同期、で削除したはずのタスクが復活します(ゾンビ問題)。

そこで削除はすべて論理削除にしました:

TypeScript

「削除」を「更新の一種」に潰してしまえば、Last-Write-Wins がそのまま削除の競合も解決してくれます。ゾンビは updated_at の比較で自然に消えます。

画面は useLiveQuery で勝手についてくる

同期で IndexedDB が書き換わったとき、画面の更新コードは 1 行も書いていません。Dexie の useLiveQuery が IndexedDB の変更を購読しているからです:

TSX

「同期処理」と「画面更新」が完全に分離されているので、同期側はデータのことだけ考えればよくなります。この分離は精神衛生に非常によいです。

まとめ

設計判断効果
書き込みは常に IndexedDB → outboxオンライン/オフラインでコードパスが同じ
flush は先頭から・失敗したら放置リトライを書かずにリトライが手に入る
Last-Write-Wins + updated_at個人用アプリには必要十分な競合解決
削除は論理削除ゾンビ復活問題を upsert に統一して解決
useLiveQuery で購読同期と画面更新の完全分離

次回はログイン編です。サーバーを持たずに Google OAuth (PKCE) を実装する話を書きます。

リンク