
ローカルファーストWeb開発のアーキテクチャ クライアント主導のデータ管理と同期の仕組み
ローカルファーストアーキテクチャが注目を集めている。従来のサーバー中心のWebアプリ開発とは異なり、クライアント端末にデータの一次コピーを保持し、読み書きをローカルで即座に処理する設計手法だ。オフラインでも動作し、ネットワーク遅延の影響を受けないため、ユーザー体験が大幅に向上する。
Smashing Magazineの記事「The Architecture Of Local-First Web Development」(2026年5月6日公開)では、実際のプロジェクト経験に基づいた実践的な知見が紹介されている。本記事ではその要点を再構成し、Web制作やシステム開発に携わるエンジニア向けにわかりやすく解説する。
ローカルファーストとは何か 〜オフライン対応との違い

ローカルファーストはよく「オフラインファースト」やPWA(プログレッシブWebアプリ)と混同される。しかしこれらは根本的に異なる。オフラインファーストはネットワーク切断時でもアプリが壊れず動くことを目的とするが、データの主たる権威(正)は依然としてサーバーにある。一方、ローカルファーストは「データアーキテクチャ」の概念であり、ユーザーの端末がデータの一次コピーを持つ。アプリはローカルデータベースに直接読み書きし、画面を即座にレンダリングする。サーバーとの同期はバックグラウンドで行われ、サーバーは認証やバックアップなど特定の役割を担うが、データの門番ではない。
Ink and Switchが2019年に提唱した「ローカルファーストソフトウェア」の7つの理想(高速、マルチデバイス、オフライン、コラボレーション、長寿命、プライバシー、ユーザー所有権)は今でも有効だが、実務において最も重要なのは「クライアントが分散システムのノードであり、独自のデータベースを持つ」という点だ。この考え方が開発全体を変える。
このデモのとおり、ローカルファーストではユーザーの操作がサーバーの応答を待つことなく完結する。この違いがアプリの「遅さ」に対する根本的な解決策になる。
オフラインファーストやPWAとの混同を解く
Service Workerを使ったキャッシュやPWAは、あくまで配信や耐障害性の仕組みだ。データの所有権や正規性は変わらない。ローカルファーストは「端末が真実のコピーを持つ」点で本質的に異なる。これを理解しないまま実装を進めると、後からデータの不整合や同期設計の誤りに悩まされることになる。
ローカルファーストが向いているユースケースと不向きな場面

このアーキテクチャは万能ではない。導入を検討する前に、自社のアプリがどのデータ特性を持つかを見極める必要がある。
適している領域
- ユーザー生成データを扱うアプリ。メモ帳、ドキュメントエディタ、プロジェクト管理、フィールド業務用ツールなど
- リアルタイムコラボレーションが必要なツール。デザインツールや同時編集が前提のアプリ
- プライバシーが売りになるサービス。データをユーザーの手元に置くことで差別化できる
- 通信が不安定な環境向けのアプリ。工事現場、僻地、移動中の利用を想定する場合
不向きな領域
- サーバー生成データが主体のアプリ。分析ダッシュボード、SNSフィード、検索結果など
- 強いトランザクション整合性が求められるシステム。銀行、決済、在庫管理(複数ユーザーが同時に在庫を操作すると問題)
- 単純なCRUDでオフラインやコラボレーションの必要がない社内管理画面。同期エンジンは過剰設計になる
- クライアント端末に収まらない巨大なデータセット
また、アプリ全体を一度にローカルファーストに書き換える必要はない。例えばブログエディタの下書き機能だけをローカルファーストにする、といった段階的な導入が現実的だ。
クライアント側のデータ保存 ストレージ技術の選択

ユーザーの端末にデータを保持するには、適切なストレージ技術を選ぶ必要がある。従来のlocalStorageは同期APIでメインスレッドをブロックし、容量も5〜10MBと限られるため、本格的なデータベース用途には使えない。現在の主流は以下の3つだ。
実案件ではwa-sqliteなどのライブラリを使い、OPFSを介してSQLiteを永続化するのが有力な選択肢だ。初期化のコード例を示す。
import { SQLiteAPI } from 'wa-sqlite';
import { OPFSCoopSyncVFS } from 'wa-sqlite/src/examples/OPFSCoopSyncVFS.js';
async function initDatabase() {
const module = await SQLiteAPI.initialize();
const vfs = new OPFSCoopSyncVFS('app-db');
await vfs.initialize(module);
const db = await module.open_v2('local.db');
await module.exec(db, `PRAGMA journal_mode=WAL`);
await module.exec(db, `
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT DEFAULT 'backlog',
created_at TEXT DEFAULT (datetime('now'))
)
`);
return db;
}なおSafariのOPFS実装は一部のコンテキストでcreateSyncAccessHandle()が無反応で失敗する既知の不具合があり、IndexedDBへのフォールバックを用意しておくことが推奨される。
データ同期の手法 CRDTとデータベースレプリケーション

クライアントにデータを置くだけなら解決済みだが、複数端末や複数ユーザー間でどう同期するかが本当の難所だ。主なアプローチは次のとおり。
YjsやAutomergeが代表的で、リアルタイム共同編集に強み。テキストの文字レベルでのマージに優れるが、構造化データのマージは意図しない結果を生むこともある。多くのプロジェクトでは、真のリアルタイム共同編集が必要な箇所にのみYjsを採用し、それ以外はデータベースレプリケーションで済ませるハイブリッド構成が無難だ。
同期の流れをコードで見る
ローカルファーストのアプリでは、従来のようにfetch()でデータを取得する必要がない。代わりにuseLiveQueryのようなフックがローカルSQLiteの変更を検知し、UIが自動で再描画される。
import { useLiveQuery } from '@powersync/react';
import { db } from '../lib/database';
function TaskBoard({ projectId }) {
const tasks = useLiveQuery(
`SELECT * FROM tasks WHERE project_id = ? ORDER BY position`,
[projectId]
);
async function addTask(title) {
await db.execute(
`INSERT INTO tasks (id, title, project_id, position)
VALUES (?, ?, ?, ?)`,
[crypto.randomUUID(), title, projectId, tasks.length]
);
// API呼び出しも楽観的更新のロールバックも不要
}
return (
{tasks.map(task => )}
);
}このコードにはローディング状態もエラーハンドリングも書かれていない。データが常にローカルにあるという前提が、これほどまでにUIコードを単純化する。
衝突解決と整合性の課題

複数のレプリカが独立して書き込みを行うと、当然データの衝突が発生する。最もシンプルな解決策は「ラストライトウィン(LWW)」、つまりタイムスタンプが新しい方を採用する方式だ。ただしレコード全体まるごと上書きするのではなく、フィールド単位で適用するのが現実的だ。下記のようなマージ関数を実装すれば、別々のフィールドを編集した場合に両方の変更が生き残る。
function pickWinner(a, b) {
const timeA = new Date(a.updatedAt).getTime();
const timeB = new Date(b.updatedAt).getTime();
if (timeA !== timeB) return timeA > timeB ? a : b;
return a.clientId > b.clientId ? a : b;
}
function mergeTask(local, remote) {
const merged = {};
const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
for (const key of allKeys) {
if (!local[key]) { merged[key] = remote[key]; continue; }
if (!remote[key]) { merged[key] = local[key]; continue; }
merged[key] = pickWinner(local[key], remote[key]);
}
return merged;
}このLWWは約95%の衝突を自動解決するが、同じテキストフィールドを2人が編集した場合は一方が静かに上書きされる。文書編集では問題だが、タスクのタイトル程度なら許容できる場合が多い。
セマンティック衝突への対処
構造的には綺麗にマージできても、意味的に矛盾するケースがある。たとえば2人のユーザーがオフラインで同じ会議室の同じ時間帯に別の予定を入れた場合、フィールド単位では衝突しないがダブルブッキングが発生する。このような「セマンティック衝突」は、サーバー側のバリデーションで検出し、クライアントに通知してユーザーに解決を促す。
重要なのは、違反が起きても書き込み自体は受け入れ、警告フラグとともにクライアントに返す設計だ。もしサーバーが書き込みを拒否すると、クライアントのローカルDBには存在するがサーバーには存在しない「幽霊レコード」が生まれ、回復が困難になる。
実装上の注意点 認証・マイグレーション・テスト

認証と認可
認証は従来どおりJWTやOAuthで行うが、トークンは毎リクエストではなく同期接続の確立時に使われる。認可は同期レイヤーで厳密に適用する必要がある。全データをクライアントに渡して見せたいものだけ表示するのは危険で、DevToolsからすべて覗ける。PowerSyncの「同期ルール」やElectricSQLの「シェイプ」を使い、サーバー側でユーザーに許可された行だけを送信する設計が必須だ。
スキーママイグレーション
サーバーなら1つのデータベースを管理すればよいが、クライアントはアプリを開いていない期間が長ければ古いスキーマのまま放置されている可能性がある。マイグレーションは起動時にバージョン番号を確認して逐次適用する方式が堅実だ。基本的に「カラムの追加」のみとし、削除やリネームは極力避ける。古いクライアントが存在する限り、欠落カラムへの書き込みが同期失敗を引き起こすからだ。
テスト戦略
マージロジックはユニットテストが容易だが、実際のネットワーク断絶や衝突タイミングを再現するのが難しい。2つのクライアントインスタンスをメモリ上で立ち上げ、同時編集後に収束するかを検証する統合テストや、Playwrightのcontext.setOffline(true)を使ったE2Eテストが有効だ。ランダムな操作列を与えて収束性をチェックするプロパティベーステストもCRDTロジックの品質を高める。
この記事のポイント
- ローカルファーストは、クライアント側にデータの一次コピーを置き、読み書きをローカルで即座に行うデータアーキテクチャである
- オフラインファーストやPWAとは異なり、データ所有権と即時性が根本的に変わる
- 向いているのはユーザー生成データを扱う協調ツールやフィールドアプリ。銀行や分析ダッシュボードには不向き
- クライアント側のストレージにはOPFS上のSQLite(WASM)が主力。IndexedDBの直接利用は避ける
- 同期はCRDT(Yjs等)とデータベースレプリケーション(PowerSync等)から選択し、多くの場合は後者で十分
- 衝突解決はフィールド単位のLWWで大半を自動化し、セマンティック衝突はサーバー検出+ユーザー通知で対応する
- 認可・マイグレーション・テストには固有の注意点があり、段階的に導入するのが現実的

・ 複数業界における17年間のデジタルビジネス開発経験
・ ウェブサイト開発のためのHTML、PHP、CSS、JavaScript等の実用的知識
・ 15ヶ国語対応の多言語SaaSの開発経験
・ 17年間にも及ぶ、Eコマース長期運営経験
・ 幅広い業界でのSEO最適化の豊富な経験

JavaScriptモジュール設計がアプリの命運を分ける!ESM時代のアーキテクチャ入門
JavaScriptで大規模なアプリケーションを構築する際、モジュールシステムをどう設計するかは、プロジェクト全体の命運を分ける最初の大きな決断となる。かつてのJavaScriptにはグローバルスコープしか存在せず、複数のスクリプトが互いの変数を上書きしてしまうリスクが常に付きまとっていた。
現代のモジュールシステムは、単にコードを複数のファイルに分割するための仕組みではない。それはシステムの各パーツの間に「境界線」を引き、依存関係の流れを制御するためのアーキテクチャそのものだ。適切な設計がなされていないモジュール構造は、プロジェクトが成長するにつれてメンテナンスを困難にし、変更のたびに予期せぬ場所でバグを発生させる原因となる。
この記事では、現代の標準であるESM(ECMAScript Modules)の特性を理解し、クリーンアーキテクチャの原則をモジュール設計にどう適用すべきかを詳しく解説していく。技術に詳しい同僚からアドバイスを受けるような感覚で、保守性の高いコードベースを構築するためのヒントを掴んでほしい。
モジュールシステムは「境界線」のデザインだ

JavaScriptのモジュールシステムには、主にCommonJS(CJS)とECMAScript Modules(ESM)の2つの流れがある。CommonJSはNode.jsの誕生とともに普及したサーバーサイド向けの仕組みであり、ESMはブラウザでもネイティブに動作するよう設計された現在の標準規格だ。これら2つは単に構文が異なるだけでなく、根本的な設計思想に大きな違いがある。
ESMがCommonJSから引き継がなかった「柔軟性」の正体
CommonJSの最大の特徴は、require()が通常の関数として実行される点にある。これにより、if文の中やループの中で動的にモジュールを読み込むことが可能だった。一方でESMは、import文を必ずファイルの先頭(トップレベル)に記述しなければならず、パスも静的な文字列である必要がある。この制約は一見不便に思えるが、実は「静的解析」を可能にするための意図的な設計だ。
ESMの制約のおかげで、ビルドツールはコードを実行することなく、どのモジュールがどこで使われているかを完全に把握できる。これが「Tree-shaking(ツリーシェイキング)」と呼ばれる、不要なコードを自動的に削除してバンドルサイズを削減する技術を支えている。CommonJSでは実行時まで依存関係が確定しないため、ツールは安全のために「使われていないかもしれないコード」もすべて含めざるを得ない。ESMは柔軟性を犠牲にすることで、パフォーマンスの最適化を手に入れたのだ。
● 実行時まで依存関係がわからない
● 不要なコードが残りやすい(低速)
● ビルド時に依存関係をすべて把握可能
● 不要なコードを自動削除(高速)
このデモは、モジュールシステムの進化によって、どのように解析のしやすさが向上したかを視覚化したものだ。
クリーンアーキテクチャに学ぶ依存関係のルール

プロジェクトの規模が大きくなると、どのファイルがどのファイルを参照しているかが複雑に絡み合い、いわゆる「スパゲッティコード」になりがちだ。これを防ぐための有力な指針として、ロバート・マーチン氏が提唱した「クリーンアーキテクチャ」がある。すべてのプロジェクトに導入すべき銀の弾丸ではないが、モジュールの境界線を引く際の強力な土台となる。
依存の方向は常に「内側」へ
クリーンアーキテクチャの核心は「依存性のルール」にある。これは、システムの各パーツを同心円状のレイヤーに分け、依存の方向を一方向に限定するというルールだ。円の内側にはビジネスロジック(システムの核心となるルール)を配置し、外側にはUI、データベース、フレームワークなどの技術的な詳細を配置する。
重要なのは、内側のレイヤーは外側のレイヤーについて何も知らないという点だ。たとえば、ユーザー登録のルール(ビジネスロジック)を記述したモジュールの中で、Reactの特定の関数や、特定のデータベース操作用のライブラリを直接インポートしてはいけない。なぜなら、技術スタックはビジネスルールよりも頻繁に変更されるからだ。技術に依存しない「コア」を保つことで、フレームワークの乗り換えやライブラリのアップデートに強いシステムが構築できる。
このデモは、依存関係が常にシステムの核心(ビジネスロジック)に向かって流れるべきであることを示している。外側の層を変更しても、内側の層には影響が及ばない構造が理想的だ。
モジュールグラフで健康状態をチェックする

自分のプロジェクトが健全な依存関係を保てているかどうかを確認するには、「モジュールグラフ」を可視化するのが効果的だ。モジュールグラフとは、ファイル同士のインポート関係を線で結んだネットワーク図のことである。MadgeやDependency Cruiserといったツールを使えば、現在のコードベースから自動的にこのグラフを生成できる。
循環参照と「やりすぎた共通ユーティリティ」の罠
不健全なグラフには、いくつかの共通した特徴がある。その筆頭が「循環参照」だ。モジュールAがBをインポートし、BがCをインポートし、さらにCがAをインポートしているような状態を指す。これはモジュールの再利用を困難にするだけでなく、ビルドエラーや予期せぬ実行時の不具合を引き起こす温床となる。
また、utils.jsのような汎用的なファイルに何でも詰め込みすぎるのも危険だ。あらゆる場所から参照される巨大なユーティリティファイルは、その一部を修正しただけでシステム全体に影響が及ぶ「爆発半径」の大きな部品になってしまう。これを解決するには、単一責任の原則に基づき、ユーティリティを機能ごとに細かく分割し、特定の文脈に閉じた場所に配置し直す必要がある。高レベルのモジュールが低レベルのモジュールに依存するという原則を、グラフを通じて常に監視することが重要だ。
バレルファイル(index.js)の使用は慎重に

JavaScript開発でよく使われる手法に「バレルファイル」がある。これは、ディレクトリ内の複数のモジュールを一つのindex.js(またはindex.ts)でまとめて再エクスポートする仕組みだ。インポート側の記述がシンプルになり、ディレクトリ構造を隠蔽できるため、コードの見た目は非常に美しくなる。
見た目の美しさとパフォーマンスのトレードオフ
しかし、バレルファイルには無視できないデメリットがある。それは、ビルドパフォーマンスの低下とTree-shakingの阻害だ。バレルファイル経由で一つの関数だけをインポートしたつもりでも、ビルドツールはそのディレクトリ内のすべてのファイルを解析対象として読み込んでしまうことがある。
大規模なプロジェクトでは、この影響が顕著に現れる。実際にAtlassianのエンジニアリングチームは、Jiraのフロントエンドからバレルファイルを削除することで、ビルド時間を75%も短縮し、バンドルサイズの削減にも成功したと報告している。小規模なプロジェクトであれば利便性が勝るが、規模が拡大してきたら「インポートの美しさ」のために「実行性能」を犠牲にしていないか、立ち止まって考える必要があるだろう。
import { login } from './auth';※auth内の全ファイルが解析されるリスクあり
import { login } from './auth/login';※必要なファイルのみが最小限に解析される
このデモで示しているように、バレルファイルは記述を簡潔にする一方で、裏側での解析コストを増大させる可能性がある。パフォーマンスが求められる現場では、直接的なインポートが推奨される場合も多い。
結合度をコントロールし保守性を高める

モジュール間の関係性を考える上で避けて通れないのが「結合度」という概念だ。結合度とは、あるモジュールが別のモジュールの内部実装にどれほど依存しているかを示す指標である。保守性の高いシステムを目指すなら、可能な限り「疎結合」な状態を保つことが求められる。
特に注意すべきは「密結合」と「暗黙的な結合」だ。密結合は、相手のモジュールの内部の仕組みを知りすぎている状態であり、一方を修正するともう一方も修正しなければならない「変更の連鎖」を引き起こす。一方、暗黙的な結合は、グローバルな状態(シングルトンやグローバル変数)を介して、目に見えない形で依存している状態だ。これらはデバッグを困難にし、コードの予測可能性を著しく低下させる。依存関係は常に明示的(Explicit)にし、モジュールが公開する「インターフェース」のみを通じてやり取りを行うのが、長期的な運用における鉄則だ。
この記事のポイント
- ESMは静的解析を可能にする制約を設けることで、Tree-shakingによる最適化を実現している
- クリーンアーキテクチャの原則に従い、依存の方向を常に「外側から内側のビジネスロジック」へ向ける
- モジュールグラフを可視化し、循環参照や肥大化したユーティリティファイルを早期に発見・解消する
- バレルファイルは小規模では便利だが、大規模開発ではビルド時間やバンドルサイズに悪影響を与える可能性がある
- 結合度を低く保ち、モジュール間のやり取りを明示的なインターフェースに限定することで保守性を高める

・ 複数業界における17年間のデジタルビジネス開発経験
・ ウェブサイト開発のためのHTML、PHP、CSS、JavaScript等の実用的知識
・ 15ヶ国語対応の多言語SaaSの開発経験
・ 17年間にも及ぶ、Eコマース長期運営経験
・ 幅広い業界でのSEO最適化の豊富な経験
