ローカルファースト・アーキテクチャとプログレッシブウェブアプリ
サービスワーカーがアプリをユーザーのデバイスへ移動させるとすれば、ローカルファーストはデータを移動させる。この一点の違いが、なぜあなたのPWAのシェルがオフラインでも即座に読み込まれる一方で、リスト、レコード、フォームのすべてがネットワークの切断とともに失敗するfetch()の上でスピナーを回し続けるのかを説明している。シェルはキャッシュされているが、データはそうではない。両者は異なるレイヤーに存在しており、そのギャップこそが、大半のPWAにおける「オフラインサポート」が静かに破綻する場所だ。
この記事は、すでにPWAをリリースした開発者——マニフェストは有効で、サービスワーカーはアセットをキャッシュし、アプリはホーム画面にインストールできる——を対象としており、「ローカルファースト」がすでに取り組んできたオフラインファーストの作業以上の何かを意味するのではないかと感じている人に向けている。実際、そうだ。ローカルファーストはデータアーキテクチャであり、ランタイムの仕組みではない。以降では、これらのレイヤーを明確に分離し、ローカルファーストPWAがどのようなスタックとして積み上げられるかを示し、ストレージとSync(同期)のオプションを概観し、このパターンがその複雑さに見合う価値を持つ場面についてPWA固有のガイドを提供する。
重要なポイント
- サービスワーカーはアセットをキャッシュしてオフラインシェルを提供する。ローカルファーストはデータアーキテクチャであり、デバイスがデータのプライマリコピーを保持し、サーバーは存在する場合にゲートキーパーではなくSyncピアとして機能する。
- ローカルファーストPWAは、サービスワーカー(アセットとオフラインシェル)、ローカルデータベース(データの読み書き)、Syncエンジン(サーバーとの結果整合性)という3つの組み合わせ可能なレイヤーで構成される——最初のレイヤーを置き換えることなく、2番目と3番目を追加する。
- IndexedDBはローカルデータストレージの実用的な最低ラインであり、WebAssemblyにコンパイルされたOPFSバックのSQLiteが現時点での上限であり、ブラウザ内でフルのリレーショナルデータベースを実現する。
- フィールドレベルのLast-Write-Wins(最終書き込み優先)は、一般的なアプリケーションにおける大多数のコンフリクトを解決する。コラボレーティブなテキスト編集にはCRDTが必要であり、セマンティックコンフリクト(2人のユーザーが同じ会議室を予約するなど)はSync中のサーバーサイドバリデーションを必要とする。
- ローカルファーストは、インストール可能なノートアプリ、フィールドサービス、コラボレーティブ編集のPWAに適している。サーバーが生成するデータのダッシュボードや、ACIDの保証に縛られるアプリには何も付け加えない。
なぜあなたのPWAのデータは依然としてネットワークに依存するのか
キャッシュされたシェルを持ちながらfetch()に依存したデータレイヤーを持つPWAは、最も狭い意味でしかオフラインではない——クロームはネットワークなしで読み込まれるが、データを必要とするすべてのコンポーネントはそこで止まる。サービスワーカーはネットワークに触れることなくCache APIからHTML、CSS、JavaScriptを再生できるため、アプリはオフラインでレンダリングされる。しかし、それらのコンポーネントが表示するデータは依然としてネットワークリクエストから来ており、動的なユーザーデータのfetch()をインターセプトするServiceWorkerは、そのデータが一度もキャッシュされていないか、キャッシュ後に変更されている場合、返すべき有用なものを持っていない。
これが、オフラインファーストのPWA対応がシェルで止まってしまう構造的な理由だ。キャッシュ戦略——キャッシュファースト、ネットワークファースト、Stale-While-Revalidate——は、どのバージョンのアセットを提供し、どの程度の鮮度を許容するかという問いへの答えだ。ユーザーのデータがどこに存在し、どのコピーが正規のものかという問いへの答えではない。Cache APIはリクエストをキーとしてHTTPレスポンスを保存する。コンポーネント内でクエリし、書き込み、読み返すデータベースではない。このギャップを埋めるには、実際のローカルデータストアと、それをサーバーと一貫した状態に保つための戦略が必要だ。それがローカルファーストだ。
ローカルファーストはオフラインファーストではなく、サービスワーカーでもない
ローカルファースト、オフラインファースト、PWA、サービスワーカーキャッシングは、互いに組み合わせ可能でありながら互換性のない4つの異なる概念だ。これらを混同することが、この分野で最もよく見られる誤解だ。
| 用語 | 概要 | 動作するレイヤー |
|---|---|---|
| PWA | デリバリーモデル:インストール可能で、マニフェスト駆動、プッシュ対応のウェブアプリ | パッケージング |
| サービスワーカー | リクエストをインターセプトしてキャッシュされたアセットを提供するランタイムの仕組み | ネットワーク / ランタイム |
| オフラインファースト | 設計目標:ネットワークが利用できない場合でも適切に機能低下し、サーバーが依然として信頼できる情報源となる | 振る舞い |
| ローカルファースト | データアーキテクチャ:デバイスがプライマリコピーを保持し、サーバーはSyncピアとなる | データ |
オフラインファーストとは、アプリケーションがネットワーク障害を適切に処理することを意味するが、サーバーは依然として信頼できる情報源であり続ける——ローカルキャッシュはリモートに従う便宜的なコピーだ。ローカルファーストはその関係を逆転させる:ユーザーのデバイスがデータのプライマリコピーを保持し、アプリケーションはローカルデータベースに対して読み書きを行い、サーバーは存在する場合、バックグラウンドで調整を行う複数のノードのうちの1つに過ぎない。この明確な分離が重要なのは、何を再利用できるかを示してくれるからだ。サービスワーカーもフレームワークも変わらない。その下にデータレイヤーを追加するだけだ。
Plainvanillaのローカルファーストのウォークスルーでは、アーキテクチャの一部としてバックエンド・フォー・フロントエンドをサービスワーカーの中に組み込んでいる。これは可能な実装の1つであり、定義ではない。レイヤーを明確に分けておくこと:ローカルファーストはデータがどこに存在し、どのコピーが正規のものかに関するものであり、サービスワーカーは同じランタイム内でたまたま利用可能な仕組みだ。
Discover how at OpenReplay.com.
ローカルファーストが実際に意味すること
ローカルファーストとは、ユーザーのデバイスがアプリケーションデータのプライマリコピーを保持し、アプリがローカルデータベースに対して読み書きを行い、サーバーは存在する場合、すべての読み書きを承認しなければならないゲートキーパーではなく、特別な権限を持つSyncピアとなるデータアーキテクチャだ。アプリケーションはユーザーの視点からは同期的にローカルデータベースに対して読み書きを行い、サーバーや他のデバイスとのSyncはバックグラウンドで非同期に行われる。このアーキテクチャ上の転換は、クライアントの再分類だ:クライアントはデータの表示許可をリクエストするシンビューであることをやめ、レプリカを所有する完全な参加者となる。
標準的な参考文献は、Ink & Switchによる2019年のエッセイLocal-First Software: You Own Your Data, in Spite of the Cloudであり、高速性、マルチデバイス対応、オフライン対応、コラボレーション性、長期利用可能性、プライバシー、ユーザーコントロールという7つの理想を示している。コードの書き方を変える単一の特性は最初のものだ:読み書きがローカルデータベースに対して行われるため、読み取りにisLoadingフラグは不要であり、書き込みはローカルの状態を即座に更新できる。ローカルへの書き込み_こそが_状態であり、SyncとコンフリクトResolveはバックグラウンドで行われる。
コンポーネントのコードでは、これによってfetchとキャッシュのやり取りが直接のクエリに集約される。{ data, isLoading, error }を返すuseQueryの代わりに、ローカルファーストのクエリはローカルデータベースをサブスクライブし、変更時に再レンダリングする:
// ローカルファースト:クエリはローカルDBを読み取り、変更時に再レンダリングする。
// isLoadingなし、読み取りのエラーバウンダリなし、キャッシュの無効化なし。
function TaskList({ projectId }) {
const tasks = useLiveTasks(projectId); // ローカルDBをサブスクライブ
return (
<ul>
{tasks.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}
useLiveTasksフックは選択したSyncエンジンまたはクエリレイヤーによって提供される。重要なのはその形だ。書き込みはローカルストアに対するシンプルなinsertであり、サブスクリプションが発火してUIが更新される。サーバーへのSyncはネットワークが許す限り行われる。
ローカルファーストPWAの3つのレイヤー
ローカルファーストPWAは3つの組み合わせ可能なレイヤーで構成される:アセットをキャッシュしてオフラインシェルを提供するサービスワーカー、ユーザーのデータを保持してすべての読み書きを処理するローカルデータベース(IndexedDBまたはOPFSバックのSQLite)、そしてローカルデータベースをバックグラウンドでサーバーと調整するSyncエンジンだ。各レイヤーは1つの関心事を担い、他のレイヤーの内部実装については何も知らない。
| レイヤー | 役割 | 担当範囲 | 担当外 |
|---|---|---|---|
| レイヤー3 — Syncエンジン | ローカルデータベースをサーバーと調整する。 | コンフリクトResolve、結果整合性、バックグラウンドのプッシュ/プル。 | レンダリング、アセットキャッシング。 |
| レイヤー2 — ローカルデータベース | ユーザーのデータを保持する。IndexedDBまたはOPFSバックのSQLite(WASM)。 | UIが実行するすべての読み書き。 | ネットワーク、オフラインシェル。 |
| レイヤー1 — サービスワーカー | アセットをキャッシュしてオフラインシェルを提供する。 | アセットのキャッシング、オフラインシェルの提供、インストール/アクティベートのライフサイクル、プッシュ。 | ユーザーデータ。 |
既存のPWAにローカルファーストを導入するにあたって、サービスワーカーやアプリケーションフレームワークを置き換える必要はない——現在fetch()を呼び出しているコンポーネントの下にデータレイヤーを追加し、それらのコンポーネントをローカルデータベースから読み取るように変更するだけだ。レイヤー1はすでにあなたのPWAに存在している。レイヤー2と3を、現在fetch()を呼び出しているコンポーネントの下に導入し、それらのコンポーネントをローカルデータベースから読み取るように変更するだけだ。
データの保存先:ローカルストレージ入門
localStorageは答えではない。同期的であるためメインスレッドをブロックし、文字列しか保存できず、MDN Web Storage APIドキュメントによれば、その容量は小さくブラウザ固有のものだ——テーマ設定の保存には適しているが、データレイヤーには不向きだ。
IndexedDBは最低ラインだ——非同期で、すべてのモダンブラウザで利用可能であり、localStorageよりもはるかに多くのデータを保持できる。ただし、ストレージクォータはオリジンベースでブラウザ固有であり、固定の上限はない。ネイティブAPIは低レベルで冗長だ——単一の書き込みにも、トランザクションのオープン、オブジェクトストアの指定、リクエストコールバックの設定が必要——そのため、ほとんどのアプリケーションはラッパーライブラリを利用する。
上限は、Origin Private File System(OPFS)に永続化されたWebAssemblyにコンパイルされたSQLiteだ。OPFSバックのSQLiteは、ブラウザ内でフルのリレーショナルデータベースを実現する——トランザクション、インデックス、そしてクライアント上でローカルに実行される使い慣れたSQLインターフェースだ。OPFSはcreateSyncAccessHandle()を通じて高性能な同期ファイルアクセスをサポートしており、これはWeb Worker内でのみ利用可能だ——まさにSQLiteが必要とするアクセスパターンだ。公式のSQLite WebAssemblyディストリビューションでは、OPFSがサポートされた永続化バックエンドとして文書化されている。
コードでは、SQLite-over-WASMに対する書き込みはサーバーサイドのSQLと同様に、リレーショナルストアに対する単一のステートメントで記述できる:
// 公式sqlite-wasmパッケージ経由のSQLite(WASM):通常のSQL。
await db.exec({
sql: "INSERT INTO tasks (id, title, done) VALUES (?, ?, 0)",
bind: [crypto.randomUUID(), "Review draft"],
});
OPFSのブラウザサポートは2025年後半時点で広く普及している——現在のブラウザ別の状況についてはMDNのFile System APIの互換性テーブルを参照してほしい。これはハードコードされたバージョンリストではなく、変化するものであるため、サポートマトリクスと照合する際の権威ある情報源だ。本番環境でよく見られる落とし穴として、OPFSの動作がブラウザエンジンや埋め込みコンテキストによって微妙に異なる場合があるため、OPFSのサポートが利用できない、制限されている、またはプライマリターゲットプラットフォームと異なる動作をするブラウザや環境向けに、IndexedDBバックのフォールバックパスを維持する価値は依然としてある。
Syncエンジンの全体像
Syncエンジンは、ローカルファーストをオフラインファースト以上のものにするレイヤーだ:ローカルレプリカをサーバーや他のデバイスと調整する。自分でゼロから構築する必要はない。いくつかの本番環境向けおよび新興のエンジンが異なるニッチを占めており、適切なものはアプリの形状と既存のバックエンドによって異なる。Syncのウェブ標準は存在しないため、各エンジンは独自のプロトコルを定義している——エンジンの交換が可能なように、Syncレイヤーは抽象化しておくこと。
- PowerSync — Postgresバックエンドをクライアントのデータベースにレプリケートし、書き戻しパスを持つ。すでにPostgresを運用しており、サーバーを再アーキテクチャすることなくオフラインサポートを追加したい場合に最適だ。
- Electric — 各クライアントが受け取るデータを定義する「Shapes」を中心に構築されたPostgres Syncエンジン。部分的なレプリケーションと、既存のPostgresバックエンド上でのユーザーごとのデータSyncを必要とするアプリケーションに最適だ。
- Replicache と Zero(いずれもRociCorpから)— 行レベルのレプリケーションよりもローカルファーストなユーザー体験を優先する、クエリ駆動のSyncシステム。クライアントサイドのミューテーション、楽観的更新、サーバーサイドの調整を中心に構築されたアプリケーションに最適だ。
- Triplit — Syncが組み込まれたフルスタックデータベースであり、クライアントとサーバーのデータベースが2つではなく1つのメンタルモデルとなる。Syncをデフォルトとして望むグリーンフィールドアプリに適している。
- Yjs と Automerge — コラボレーティブ編集のためのCRDTライブラリ。複数のユーザーが同じリッチテキストや構造化ドキュメントを同時に編集し、文字レベルのマージが必要な場合に適した選択肢だ。
ユーザーが所有するレコードをSyncするほとんどの業務系PWAでは、CRDTライブラリよりも既存のデータベース上の行レプリケーションエンジンの方が適している。YjsやAutomergeは、リアルタイムのコラボレーティブなテキスト編集が製品の核心である場合に選択すべきであり、汎用的なコンフリクト解決策として使うべきではない。
コンフリクトの解決
2つのレプリカが互いの変更を参照せずに同じデータを変更した場合、Syncエンジンはそれらを調整しなければならない。コンフリクトは3つのカテゴリに分類され、それぞれ異なる解決戦略を持つ。
-
構造的コンフリクト——フィールドレベルのLast-Write-Wins。 フィールドレベルのLast-Write-Winsは、一般的なアプリケーションにおける大多数のコンフリクトを処理する:2人のユーザーがオフラインで同じレコードの異なるフィールドを編集した場合、両方の変更が保持される。同じフィールドを編集した場合は、タイムスタンプが新しい方が優先される。これはローカルファーストコミュニティで広く使われている経験則であり、測定された定数ではない——Martin KleppmannのDesigning Data-Intensive Applicationsでは、Last-Write-Winsの一貫性トレードオフについて詳しく解説されている。
-
コラボレーティブテキストのコンフリクト——CRDT。 2人のユーザーが同じ段落に入力した場合、Last-Write-Winsは一方のキー入力を破棄してしまう。Conflict-Free Replicated Data Types(コンフリクトフリーレプリケーテッドデータタイプ)は、文字レベルで同時編集をマージし、両方の文字セットが一貫して表示されるようにする。YjsとAutomergeがこれを実装しており、マージの仕組みは異なるが、保証は同じだ——同時編集は中央コーディネーターなしに収束する。
-
セマンティックコンフリクト——サーバーサイドバリデーション。 構造レベルでは問題なくマージされるが、ドメインの不変条件に違反する結果を生み出すコンフリクトがある。例: 2人のオフラインユーザーがそれぞれ同じ会議室を午後2時に予約する。両方の書き込みは異なるレコードを対象とするため、フィールドレベルのマージは両方を受け入れる——構造的には問題ないが、ダブルブッキングになる。データは問題なくマージされるがドメインの不変条件に違反するセマンティックコンフリクトは、Sync中のサーバーサイドバリデーションを必要とする。データ損失を防ぐパターンは、コンフリクトする書き込みを受け入れ、違反にフラグを立て、サーバーが静かに拒否するのではなく、解決可能な通知としてユーザーに提示することだ——拒否はクライアントにサーバーが認めないレコードを残すことになる。
ローカルファーストPWAで最も厄介なバグは、Syncの失敗ではない——それはログに現れる。問題は、サイレントな上書きだ:楽観的な書き込みはローカルで成功し、Syncはエラーなく完了するが、ユーザーの変更はLast-Write-Winsレースに勝ったリモートバージョンによって静かに置き換えられる。この失敗はサーバーログ、エラーモニタリング、ネットワークトレースには現れない。楽観的な更新とそれに続くサイレントな巻き戻しの両方を記録するツール——つまりUIの状態シーケンスを記録するツール——でのみ、視覚的なシーケンスとして確認できる。セッションリプレイは、このミスマッチがクリーンなログ行ではなく、視聴可能なシーケンスとして現れる数少ない場所の1つだ。
ローカルファーストがあなたのPWAに適しているとき
ローカルファーストは、アプリが管理するデータがユーザーによって所有されており、即時のローカルインタラクションから恩恵を受ける場合にPWAに適している。データがサーバーによって生成されるか、強いトランザクション保証に縛られている場合には何も付け加えない。決定的な問いは、抽象的なアプリカテゴリではなく、データがデバイス上に存在すべきかどうかだ。
| PWAのユースケース | ローカルファーストは有効か? | 理由 |
|---|---|---|
| インストール可能なノートアプリ | 有効 | ユーザー所有のデータ、即時の読み書き、オフライン編集が中核的な価値であり、インストール可能なシェルとローカルデータが互いを補強する。 |
| 不安定な接続環境でのフィールドサービスアプリ | 有効 | 作業はネットワークが弱い場所で行われる。書き込みはオフラインで成功し、電波が回復したときにSyncされなければならない。 |
| コラボレーティブ編集ツール | 有効 | 同時編集が製品そのものであり、CRDTバックのSyncがキーストロークごとのラウンドトリップなしにリアルタイムマージを実現する。 |
| アナリティクスダッシュボード | 無効 | サーバーがデータを生成する。シェルのキャッシングは有用だが、ユーザーが所有も変更もしないデータにローカルファーストは何も付け加えない。 |
| Eコマースのチェックアウト | 無効 | 支払いと在庫管理にはACID保証と単一の権威あるデータベースが必要だ。結果整合性は在庫の過剰販売や二重請求を引き起こす可能性がある。 |
| ソーシャルフィード | 無効 | フィードはサーバーがランク付けし、サーバーが所有する。クライアントはそれを作成するのではなく消費する。 |
このパターンは、ユーザーがデータを作成し所有する場面でPWAの強みと完全に一致する。ノートアプリのPWAでは、インストール可能なシェル、オフライン書き込み、バックグラウンドSyncが組み合わさって、ネイティブアプリに近い体験を実現する。ダッシュボードでは、サービスワーカーがシェルをキャッシュして読み込みを高速化できるが、数値は依然としてサーバーから来る——ローカルファーストは、そのユースケースが持っていない問題を解決しようとするものだ。
データレイヤーを追加するのであって、アプリを作り直すのではない
既存のPWAにローカルファーストを後付けするということは、2つのレイヤーを追加することを意味し、既存のものを作り直すことではない:現在fetch()でスピナーを回しているコンポーネントの下にローカルデータベースとSyncエンジンのスロットを追加し、サービスワーカーとフレームワークはそのままにする。すでに行ったPWAの作業を置き換えるのではなく、それを完成させるのだ——サービスワーカーがアプリをユーザーに移動させたように、データをユーザーに移動させる。具体的な次のステップは小さい:ユーザーが所有・編集するデータを持つ1つの機能を選び、IndexedDBまたはOPFSバックのSQLiteでバックアップし、そのコンポーネントをそのストアから読み取るように変更し、Syncエンジンにバックグラウンドで調整させる。その1つの機能が、さらに読み進めるよりも早く、アプリの残りの部分がそこに属するかどうかを教えてくれるだろう。
よくある質問
通常は必要ですが、その役割が変わります。ローカルファーストアーキテクチャでは、サーバーはすべての読み書きを承認するゲートキーパーであることをやめ、デバイス間でローカルデータベースを調整し、耐久性のあるコピーを永続化し、ダブルブッキングなどのセマンティックコンフリクトに対してサーバーサイドバリデーションを実施するSyncピアとなります。ACID保証、支払い、または権威ある共有状態を必要とするアプリは依然として本格的なバックエンドを必要とします。変わるのは、UIを通じた読み書きのパスがローカルデータベースに移動することだけです。
いいえ。両者は異なるレイヤーで動作し、競合するのではなく組み合わさります。サービスワーカーはアセットをキャッシュしてオフラインシェルを提供し、ローカルファーストは現在fetchを呼び出しているコンポーネントの下にローカルデータベースとSyncエンジンを追加します。既存のPWAにローカルファーストを後付けするということは、サービスワーカーとフレームワークをそのままにして、その下にデータとSyncのレイヤーを導入することを意味し、レイヤー1を書き直すことではありません。
同じリッチテキストや構造化ドキュメントの同時編集が製品そのものである場合にCRDTを選択してください。Last-Write-Winsは2人のユーザーが同じ段落に入力した場合に一方のキー入力を破棄するからです。Conflict-Free Replicated Data Typesは文字レベルで同時編集をマージし、中央コーディネーターなしに両方の文字セットが収束するようにします。ユーザー所有のレコードをSyncするほとんどの業務系PWAでは、行レプリケーションエンジン上のフィールドレベルのLast-Write-Winsの方が適しています。YjsやAutomergeはリアルタイムのコラボレーティブなテキスト編集のために取っておいてください。
これはサイレントな上書きです。楽観的な書き込みはローカルで成功し、Syncはエラーなく完了しますが、リモートバージョンがLast-Write-Winsレースに勝ち、ローカルの変更を静かに置き換えます。この失敗はサーバーログには現れません——サーバーログは成功を示します。エラーモニタリングには現れません——例外はスローされません。ネットワークトレースには現れません——リクエストは通過しています。UIの状態シーケンスを記録するツール——楽観的な更新とそれに続く巻き戻しの両方をキャプチャするツール——でのみ確認できます。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.