Skip to content
Published on

Local-Firstソフトウェア設計 — クラウド疲労時代の代替アーキテクチャ

Authors

はじめに — クラウド疲労という時代背景

2026年上半期の技術コミュニティの雰囲気を一言で要約するなら「ビッグテック疲労」です。Hacker Newsでは、Gmailを離れてセルフホストのメールや小規模な有料サービスに移ったという記事が定期的にトップに上がり、AI要約を挟まない検索を求めるユーザーが集まってDuckDuckGoのno-AI検索トラフィックが急増したというニュースも話題になりました。GeekNewsでも「自分のデータはどこにあるのか」というテーマの記事が常に上位に留まっています。

サブスクリプションサービスが終了すると自分の文書も一緒に消え、サーバー障害が起きると目の前の正常なコンピュータでも仕事ができず、利用規約の変更1つで自分のデータがAIの学習材料になる — そんな経験の蓄積の結果です。この疲労感への技術的な回答として再び呼び出されているのがLocal-Firstソフトウェアです。2019年にInk and Switch研究所がエッセイとしてまとめたこの概念は、クラウドのコラボレーションの利便性とローカルソフトウェアの所有権を同時に掴もうとする設計哲学です。

本記事はマニフェストの紹介にとどまらず、実際にlocal-firstプロダクトを設計する際にぶつかる問題 — 同期エンジンの選択、競合解決、サーバーの役割の境界、スキーマ進化 — をアーキテクチャの観点から整理します。

Local-Firstの7つの理想

Ink and Switchのエッセイは、local-firstソフトウェアが満たすべき7つの理想を提示しました。

  1. 高速な応答。スピナーなしで、すべての操作がローカルデータで即座に処理されるべきです。
  2. 複数デバイス。自分のデータは自分が使うすべてのデバイスからアクセスできるべきです。
  3. ネットワークはオプション。オフラインが異常状態ではなく、デフォルトでサポートされる状態であるべきです。
  4. シームレスなコラボレーション。Google Docsレベルのリアルタイム共同編集が可能であるべきです。
  5. 永続性。サービス企業が消えても、自分のデータとソフトウェアは動き続けるべきです。
  6. デフォルトのセキュリティとプライバシー。エンドツーエンド暗号化により、サーバー運営者でも内容を見られないようにすべきです。
  7. 完全な所有と統制。データの法的・技術的なコントロール権はユーザーにあるべきです。

核心となる洞察はこれです。1番から3番まではキャッシュとオフラインモードで模倣できますが、4番(コラボレーション)と組み合わさった瞬間、難易度が爆発します。複数のデバイスで同時に修正されたデータをマージしなければならないからです。local-firstアーキテクチャの本質は、結局のところ「分散システムのマージ問題をクライアントに持ってくること」なのです。

アーキテクチャの転換 — サーバー中心からローカル優先へ

従来のSaaSとlocal-firstのデータフローを比較すると、違いが明確になります。

[従来のクラウドSaaS]
  クライアントA          サーバー (真実の源)          クライアントB
  UI ---- リクエスト ---> DBが唯一の状態  <--- リクエスト ---- UI
  UI <--- レスポンス ---  全読み書きが経由  ---- レスポンス --> UI
  * ネットワークが切れると何もできない
  * 遅延 = 往復時間(RTT)がUI応答性の下限

[Local-First]
  クライアントA                              クライアントB
  UI -- 即時読み書き --> ローカルDB           ローカルDB <-- 即時読み書き -- UI
                            |                  |
                            +--- 同期エンジン ---+
                                     |
                              サーバー = 同期の中継
                              (保存、転送、バックアップ)
  * UIは常にローカルDBだけを見る (遅延 = ディスクアクセス)
  * サーバーは変更を中継・保管する郵便局の役割

この転換が意味するところを整理すると次のとおりです。

第一に、真実の源が移動します。サーバーDBが唯一の真実なのではなく、各クライアントのローカルレプリカがすべて第一級市民になります。サーバーは変更ログの中継者であり、耐久性のある保管庫へと役割が縮小します。

第二に、書き込み経路が非同期になります。ユーザーの書き込みはローカルに即座にコミットされ、同期はバックグラウンドで行われます。したがって「書き込み成功」の意味が2段階(ローカル確定、伝播完了)に分離され、UIはこれを区別して表現する必要があります。

第三に、競合が正常な状況になります。サーバー中心モデルでは競合はトランザクションとロックで予防する例外でしたが、local-firstでは必ず起こる日常であり、マージ戦略が設計の中心になります。

同期エンジンの内部構造

同期エンジンはlocal-firstスタックの心臓部です。一般的な構成は次のとおりです。

+------------------- クライアント -------------------+
|  UI (React, Swift, ...)                            |
|   |  購読(クエリ)            ^ リアクティブ更新       |
|   v                         |                      |
|  ローカルストア (SQLite, IndexedDB, OPFS)            |
|   |  変更キャプチャ(oplog)    ^ リモート変更の適用     |
|   v                         |                      |
|  同期クライアント (リトライ、バックオフ、圧縮)          |
+-----------|---------------^------------------------+
            v (push)        | (pull / リアルタイムストリーム)
+-----------|---------------|------------------------+
|  同期サーバー: 変更ログの保存、順序付け、ファンアウト、  |
|              権限フィルタリング、スナップショット圧縮    |
+----------------------------------------------------+

設計時に決めるべき軸は大きく4つです。

  1. 同期の単位。文書全体か、行単位か、操作(op)単位か。単位が小さいほど競合解決の精度は上がりますが、メタデータのコストが増えます。
  2. 転送モデル。定期的なpull、リアルタイムWebSocketストリーム、プッシュ通知トリガーをどう組み合わせるか。
  3. 部分同期。クライアントが全データセットを持てない場合、どの部分集合をどう宣言的に購読させるか。最近のエンジンが「クエリベース同期」に収束しているポイントです。
  4. 圧縮とスナップショット。変更ログは無限に成長するため、いつスナップショットに畳んで古いログを捨てるか。

CRDT — 基礎は簡潔に、設計の観点から

CRDT(Conflict-free Replicated Data Type)の内部アルゴリズムは当ブログの別記事で扱っているため、ここでは設計者が知るべき最小限だけを押さえます。

CRDTは「どの順序でマージしてもすべてのレプリカが同じ状態に収束する」という数学的性質を持つデータ構造です。カウンター、集合、リスト、テキストなどタイプごとに実装があり、AutomergeとYjsが代表的なライブラリです。中央調整者なしでマージが完結する点が魅力ですが、設計の観点では次の限界を知っておく必要があります。

  • 収束は意図の保存と同じではありません。2人が同じタスクを別々のフォルダに移動した場合、CRDTは何らかの結果に収束しますが、その結果が2人どちらの意図とも違う可能性があります。
  • 不変条件の保証が困難です。「残高は負になれない」のようなグローバル不変条件は、ローカルマージだけでは守れません。こうしたドメインにはサーバーの仲裁が必要です。
  • メタデータコストがあります。削除痕(tombstone)と因果関係追跡情報が蓄積され、テキストCRDTは文書の長さよりはるかに大きい内部状態を持つことがあります。最近の実装は大きく改善されましたが、タダではありません。

実務上の結論はシンプルです。共同編集テキストやホワイトボードのように意図の競合が頻繁でリアルタイム性が重要な場所にはCRDTを、構造化されたビジネスデータにはよりシンプルな戦略を使う混合設計が主流です。

競合解決戦略3種の比較

戦略動作方式長所短所適したデータ
LWWタイムスタンプが最新の書き込みが勝つ実装がシンプル、メタデータ最小同時編集の片方が静かに失われる設定値、単一フィールド更新
CRDT数学的マージで自動収束オフラインマージが無損失、サーバー不要意図の保存は未保証、状態が肥大化共同編集文書、描画
サーバー仲裁サーバーが変更を受けてルールで再配列不変条件を強制可能、検証が容易オフラインでの確定不可、サーバー依存在庫、決済、権限変更

実際のプロダクトは3つをデータの種類ごとに混ぜます。ノート本文はCRDT、ノートのタイトルとタグはLWW、共有権限の変更はサーバー仲裁 — このような分割が標準パターンです。

同期エンジンのエコシステム比較

2026年現在、選択肢は豊富になりました。実際に検討した範囲で整理します。

エンジンアプローチローカルストアサーバー側特徴の要約
ElectricSQLPostgresの変更をクライアントへストリーミングクライアントが選択Postgres前段の同期サービス読み取り経路の同期に集中、書き込みは自前API
Zeroクエリベースの同期独自クライアントキャッシュ独自キャッシュサーバーとPostgresクエリを購読すると結果がリアクティブに維持
PowerSyncPostgresなど既存DBをSQLiteへミラーリングSQLite同期サービス既存バックエンドを維持した漸進導入に強み
AutomergeCRDT文書ライブラリ独自文書フォーマット中継サーバーはシンプルInk and Switch系列、文書モデルに適合
YjsCRDTライブラリ独自タイプ中継サーバーはシンプルエディタ統合エコシステムが最も豊富
Triplitフルスタック同期DB独自ストア独自サーバークエリ購読と権限を一体で提供

選択基準を1行ずつに圧縮するとこうなります。既存のPostgresバックエンドがあり漸進導入を望むならPowerSyncかElectricSQL、共同編集エディタが核心ならYjsかAutomerge、新プロダクトを最初からlocal-firstで作るならZeroやTriplitのような統合型が候補になります。

サーバーは消えない — 境界の設計

local-firstという名前から誤解しやすいのですが、サーバーは消えません。役割が変わるだけです。次の領域は構造的にサーバーに残ります。

  • 認証。本人確認は本質的に第三者の検証が必要です。トークンの発行と更新はサーバーの仕事です。
  • 権限。「この文書を誰が見られるか」は、同期サーバーがファンアウト時点で強制しなければなりません。クライアント側のフィルタリングはセキュリティではありません。
  • 課金。決済とサブスクリプション状態はグローバル不変条件の代表例で、サーバー仲裁が必須です。
  • 重い演算。全文検索インデックス構築、AI推論、大容量メディア変換はサーバーやエッジで実行し、結果を同期で配り下ろすのが合理的です。

境界設計の実用的な原則は「書き込みを2つのクラスに分けよ」です。ローカルで確定可能な書き込み(文書編集、個人設定)は同期エンジンへ、サーバー確定が必要な書き込み(共有、決済、招待)は従来型のAPI呼び出しで処理し、その結果の状態は再び同期ですべてのデバイスに伝播させます。この2つの経路を最初から分離しておくと、後の混乱が大きく減ります。

二手に分かれる書き込み経路を図で整理すると次のとおりです。

ユーザー操作
   |
   +-- ローカル確定可能? --- はい --> ローカルDBコミット --> oplog --> 同期エンジン
   |                                  (即座にUI反映)              (バックグラウンド伝播)
   |
   +-- サーバー確定が必要? --- はい --> API呼び出し --> サーバー検証/コミット
                                        |                  |
                                  失敗時はオフラインキュー   状態変更を同期で
                                  (リトライポリシー適用)    全デバイスにファンアウト

オフラインUXパターン

アーキテクチャがオフラインをサポートしても、UXが追いつかなければユーザーは不信感を持ちます。実証済みのパターンをいくつか整理します。

  1. 楽観的UIを基本としつつ、同期状態は1箇所に静かに表示します。文書ごとにスピナーを付ける代わりに、ステータスバーに「最終同期時刻」が1つあれば十分です。
  2. 競合をユーザーに押し付けません。自動マージを基本とし、本当に必要な場合にのみ「両方のバージョンを見る」を提供します。マージダイアログが頻繁に出るアプリは設計が間違っています。
  3. サーバー確定が必要な操作は、オフライン時に明確に区別します。たとえば共有招待ボタンは、オフラインでは「接続時に送信されます」という状態でキューイングされることを見せます。
  4. 初回同期(初期ダウンロード)は進捗を表示し、それ以降は同期プロセスを可能な限り見えなくします。

ローカルストアの選択 — ウェブの現実

ネイティブアプリはSQLite一択に落ち着きますが、ウェブは選択肢が分かれます。

ストア性格強み弱み
IndexedDBブラウザ組み込みのKVとインデックスどこでも動く、追加依存なしAPIが不便、大量書き込み性能に限界
SQLite WasmとOPFSブラウザでSQLiteを実行本物のSQL、ネイティブとコード共有Wasmバンドルサイズ、ワーカー構成が複雑
OPFSを直接使用オリジン専用ファイルシステム高速なファイルIO低レベルでフォーマット設計が自前

2026年時点の流れは、明確にSQLite WasmとOPFSの組み合わせに傾きました。ネイティブアプリ、サーバー、ウェブが同じSQLスキーマとクエリを共有できる点が決定的です。同期エンジンもSQLiteを第一級ターゲットとしてサポートする傾向にあります。

スキーマ進化 — local-firstの隠れた難題

サーバー中心アプリのマイグレーションはデプロイ時に1回実行すれば終わりです。local-firstでは、旧バージョンのクライアントが数ヶ月後にオフラインの変更を抱えて戻ってくることがあります。設計原則は次のとおりです。

  • スキーマバージョンをデータに刻みます。すべての変更ログにスキーマバージョンを含め、受信側がどの変換を適用すべきか分かるようにします。
  • 前方互換を優先します。フィールド追加は安全に、フィールド削除と意味変更は慎重に。削除の代わりに非推奨(deprecation)期間を設けます。
  • 変換は読み取り時に行います。保存済みデータを一括書き換えする代わりに、読むときに旧レコードを新しい形に引き上げるlazy migrationが分散環境と相性が良いです。
  • 破壊的変更は新しいコレクションで行います。本当に壊す必要があるなら、新しいテーブルを作って二重書き込み期間を経て移行する、分散システムの古典的手法に従います。

実践ミニ設計 — ノートアプリのシナリオ

ここまでの原則を小さなノートアプリに適用してみましょう。要件はマルチデバイス同期、オフライン編集、ノート共有です。

まずデータモデルです。競合戦略をデータの種類ごとに分離します。

-- ローカルSQLiteスキーマ (クライアントごとに同一)
CREATE TABLE notes (
  id          TEXT PRIMARY KEY,   -- UUIDv7: 作成時刻でソート可能
  title       TEXT NOT NULL,      -- 戦略: LWW
  body_crdt   BLOB NOT NULL,      -- 戦略: CRDT (Yjs文書バイナリ)
  folder_id   TEXT,               -- 戦略: LWW
  updated_at  INTEGER NOT NULL,   -- ハイブリッド論理時計(HLC)
  deleted     INTEGER DEFAULT 0,  -- ソフト削除 (tombstone)
  schema_v    INTEGER DEFAULT 1
);

CREATE TABLE oplog (
  seq         INTEGER PRIMARY KEY AUTOINCREMENT,
  note_id     TEXT NOT NULL,
  op_type     TEXT NOT NULL,      -- upsert_meta | crdt_update | delete
  payload     BLOB NOT NULL,
  hlc         TEXT NOT NULL,      -- 競合比較用のハイブリッド時計
  synced      INTEGER DEFAULT 0
);

同期クライアントの骨格は次のとおりです。

// sync-client.ts — 単純化した同期ループ
async function syncLoop(db: LocalDB, server: SyncTransport) {
  while (true) {
    // 1. push: 未送信のローカル操作をサーバーへ
    const pending = await db.query(
      "SELECT * FROM oplog WHERE synced = 0 ORDER BY seq LIMIT 100"
    );
    if (pending.length > 0) {
      await server.push(pending);
      await db.markSynced(pending.map((p) => p.seq));
    }

    // 2. pull: サーバーカーソル以降のリモート操作を受信
    const remote = await server.pull(db.getCursor());
    for (const op of remote) {
      applyRemoteOp(db, op); // 下記のマージルールを適用
    }
    await db.setCursor(remote.cursor);

    await server.waitForChangeOrTimeout(30_000); // リアルタイム通知 or フォールバック
  }
}

function applyRemoteOp(db: LocalDB, op: RemoteOp) {
  switch (op.op_type) {
    case "crdt_update":
      // CRDTマージ: 順序に依存せず常に安全
      db.mergeCrdt(op.note_id, op.payload);
      break;
    case "upsert_meta":
      // LWWマージ: HLCが大きい方だけ反映
      db.applyIfNewer(op.note_id, op.payload, op.hlc);
      break;
    case "delete":
      db.softDelete(op.note_id, op.hlc);
      break;
  }
}

共有はサーバー確定経路に分離します。

// 共有招待: サーバー仲裁が必要な書き込みの例
async function inviteCollaborator(noteId: string, email: string) {
  // 従来型のAPI呼び出し — サーバーが権限テーブルを更新し
  // 結果を同期ストリームですべての参加者デバイスに伝播する
  const res = await api.post("/shares", { noteId, email });
  if (!res.ok) {
    enqueueRetry({ kind: "invite", noteId, email }); // オフラインキュー
  }
}

この小さな設計の中に、先に述べた原則がすべて入っています。本文はCRDT、メタデータはLWWとHLC、権限はサーバー仲裁、削除はtombstone、スキーマバージョンはレコードに内蔵。実際のプロダクトはここにスナップショット圧縮と部分同期(フォルダ単位の購読)を加えることになります。

部分同期の設計を深掘り — クエリ購読パターン

ノートが10万件あるユーザーのモバイルデバイスに全データをダウンロードさせるわけにはいきません。最近の同期エンジンが収束しつつある答えは、宣言的なクエリ購読です。クライアントが「自分が見たい部分集合」をクエリとして宣言すると、エンジンがその結果集合をローカルに実体化し、変更を反映し続けます。

// クエリ購読パターンの概念的な例
// 「自分のフォルダのうち直近30日以内に更新されたノート」だけをローカルに保持
const subscription = syncEngine.subscribe({
  table: "notes",
  where: {
    folder_id: { in: myFolderIds },
    updated_at: { gte: daysAgo(30) },
  },
  include: ["attachments_meta"], // 添付はメタデータのみ
});

// 購読結果はリアクティブ — リモート変更が届くとUIが自動更新
subscription.onChange((rows) => renderNoteList(rows));

// 画面を離れたら購読解除 → ローカルキャッシュの寿命管理
subscription.unsubscribe();

このパターンを設計するときの注意点が3つあります。

第一に、購読境界を越える移動です。ノートが購読範囲外のフォルダに移動するとローカルから消える必要がありますが、ユーザーには削除のように見えることがあります。「未ダウンロード」状態をUIで区別する必要があります。

第二に、権限と購読の結合です。サーバーは購読クエリを評価する際、必ず権限フィルタを併せて適用しなければなりません。クライアントが送ったクエリを信頼した瞬間、データ流出の通路になります。

第三に、添付ファイルのような大容量ブロブです。ブロブはoplogに載せず、メタデータだけ同期した後、内容はコンテンツアドレス(ハッシュ)ベースで遅延ダウンロードするのが定石です。

同期コードのテスト戦略

同期バグは再現が難しいことで悪名高いです。幸い、local-firstアーキテクチャはテストしやすい性質を1つ持っています。マージロジックが純粋関数に近いという点です。次の3層でテストを積み上げることをお勧めします。

// 第1層: マージルールのプロパティベーステスト
// 「どの順序で適用しても最終状態が同じ」をランダムな操作列で検証
test("convergence: any order of ops yields identical state", () => {
  const ops = genRandomOps(50); // ランダムな操作を50個生成
  const stateA = applyAll(emptyDb(), shuffle(ops));
  const stateB = applyAll(emptyDb(), shuffle(ops));
  expect(canonical(stateA)).toEqual(canonical(stateB));
});

// 第2層: シナリオテスト — 2つの偽クライアントと偽サーバー
test("offline edit on two devices merges without loss", async () => {
  const server = new FakeSyncServer();
  const alice = new FakeClient(server);
  const bob = new FakeClient(server);

  await alice.sync();
  await bob.sync();

  alice.goOffline();
  bob.goOffline();
  alice.editBody("note1", "アリスが文章を追加");
  bob.editBody("note1", "ボブが別の段落を修正");

  alice.goOnline();
  await alice.sync();
  bob.goOnline();
  await bob.sync();
  await alice.sync(); // ボブの変更を受信

  expect(alice.read("note1")).toEqual(bob.read("note1"));
  expect(alice.read("note1")).toContain("アリスが文章を追加");
  expect(alice.read("note1")).toContain("ボブが別の段落を修正");
});

第3層はカオステストです。ネットワーク切断、メッセージの重複配送、順序の入れ替わり、クライアントの強制終了をランダムに注入しながら、収束性とデータ無損失を長時間検証します。同期エンジンを自作せず既製エンジンを使う場合でも、自分のマージルール(LWWフィールドの選択、削除ポリシー)については第1層と第2層のテストを自前で備えるのが良いでしょう。

導入判断フレームワーク — 自分たちのプロダクトに合うか

最後に、local-first導入の可否を判断する実用的な質問リストです。正直に答えてみてください。

  1. データの持ち主が明確に個別ユーザー(または小規模チーム)か。そうであれば適合度は高いです。全社共有ダッシュボードなら低いです。
  2. オフラインや低速ネットワークで使うシナリオが実際に存在するか。移動中のメモ、飛行機、現場作業のような具体的な場面が浮かばなければ、費用対効果は低いです。
  3. グローバル不変条件が核心的価値か。座席予約のように「同時に1人だけ」が本質なら、サーバー中心が正解です。
  4. リアルタイムコラボレーションがロードマップにあるか。あるなら、最初からCRDTフレンドリーなデータモデルを組む方が、後で作り直すより圧倒的に安くつきます。
  5. チームが分散システムのデバッグを担えるか。HLC、因果順序、冪等性といった概念に詳しい人が少なくとも1人は必要です。
  6. ユーザー1人分のデータがデバイスに収まるか。数GBを超えるなら部分同期の設計が必須となり、難易度が一段上がります。

6つのうち1、2、4番が「はい」なら真剣に検討する価値があり、3番が「はい」なら該当ドメインだけをサーバー仲裁に分離する混合設計を検討してください。

ビジネスモデルの観点

local-firstは技術選択であると同時にビジネス選択です。データを人質に取るロックイン効果が弱まるため、別の価値で課金する必要があります。市場で実証されたモデルは3つです。

  1. 同期サービスへの課金。ソフトウェアは無料ですが、マルチデバイス同期とコラボレーション中継にサブスクリプション料金を課します。同期サーバーはセルフホスティングの選択肢を開けておきつつ、利便性で差別化します。
  2. 買い切りモデルへの回帰。永続性の理想と噛み合い、サブスクリプション疲れのユーザー層に「一度買えばずっと使える」モデルが再び訴求力を得ています。
  3. チーム・エンタープライズ機能への課金。個人利用は無料で開放し、権限管理、監査ログ、SSOのようにサーバーが本質的に必要な機能に課金します。サーバー必須領域と課金領域を一致させる綺麗な構造です。

逆説的に、local-firstは離脱障壁が低いためプロダクト品質でユーザーを引き留める必要があり、これが長期的にはより健全なインセンティブ構造だという主張がコミュニティの大勢です。

限界と批判的視点

バランスのために反対側の論拠も整理します。

複雑性のコストは実在します。サーバー中心のCRUDには数十年かけて磨かれたパターンとツールがありますが、local-firstは同期、マージ、スキーマ進化の複雑性をすべての機能開発に定数として追加します。小さなチームが担えるか冷静に評価すべきです。

すべてのアプリに合うわけではありません。銀行、在庫、予約のようにグローバル不変条件が核心のドメインはサーバー仲裁が本質であり、local-firstの利得は少ないです。逆に、文書、ノート、デザイン、個人ナレッジ管理のようにユーザー所有データ中心のドメインが最適な適用先です。

エンドツーエンド暗号化とコラボレーション機能は緊張関係にあります。サーバーが内容を見られないと、サーバー側の検索、スパムフィルタリング、AI機能が難しくなります。7つの理想をすべて同時に満たすプロダクトはまだ稀で、ほとんどは一部を意識的に諦めています。

エコシステムがまだ若いです。同期エンジンのAPIは急速に変化しており、長期運用の事例が十分に蓄積されていません。5年後も維持されるエンジンを選ぶこと自体がリスク管理です。

おわりに

Local-Firstは「クラウドかローカルか」の二者択一ではなく、真実の源をユーザー側に移し、サーバーを中継者として再配置するアーキテクチャの再設計です。クラウド疲労という時代の情緒が需要を作り、SQLite Wasmと成熟した同期エンジンが供給を作ることで、2026年のlocal-firstは理想主義のマニフェストから実用的な技術スタックへと降りてきました。

始める最良の方法は小さく実験することです。既存プロダクトの読み取り経路を1つ、PowerSyncやElectricSQLでローカルにミラーリングしてみるか、サイドプロジェクトのノートアプリを上記のミニ設計どおりに作ってみてください。スピナーが消えたUIを一度体験すると、往復遅延の上に積み上げてきた既存の設計が違って見え始めるはずです。

参考資料