The Rise of Local-First Web Applications: Architecture & Tools
Dive deep into the architecture of local-first web applications. Learn how CRDTs (Conflict-free Replicated Data Types), local state sync, IndexedDB, and tools like RxDB, Yjs, and Electric SQL enable offline-capable, collaborative, and instant-loading apps.

For decades, the standard pattern of web development has been cloud-first. In this model, the client browser behaves like a "thin client" or a glorified window. Every keypress, click, or state change triggers a network request to an API server. If a database transaction fails on the remote server, or if the user goes offline, the application grinds to a halt.
Local-first web applications reverse this pattern. Under a local-first architecture, the local device is the primary source of truth. Data is stored locally in the browser's database, operations are completed instantly with zero network latency, and synchronization with other clients/servers happens in the background.
This guide explores the design principles, conflict reconciliation strategies, storage stack, and implementation blueprints of local-first web architecture.
1. The Core Pillars of Local-First Software
In 2019, research lab Ink & Switch published a seminal paper on Local-First software, detailing seven key requirements. Let's look at the three architectural pillars that define how we implement local-first web apps today:
+-----------------------------------------------------------+
| UI Layer (React) |
+-----------------------------------------------------------+
| (Reactive Read/Write)
v
+-----------------------------------------------------------+
| Local Storage Engine |
| (IndexedDB / OPFS / SQLite Wasm Instance) |
+-----------------------------------------------------------+
| (Sync Engine)
v
+------------------+ +-------------------+
| Sync Provider | <----------------> | Remote Sync |
| (Yjs / Automerge)| (WebSocket) | Server / Peer |
+------------------+ +-------------------+
- No Latency Reads and Writes: Writes are committed to the client's local memory or disk instantly. There are no "loading spinners" while waiting for the database to update.
- Offline-Capable by Design: The application retains full read/write functionality whether the device is in a subway tunnel or on an airplane.
- Multi-device Sync & Collaboration: When the network becomes available, the local engine syncs seamlessly, merging conflicts without discarding user edits.
2. Resolving Conflicts: Last-Write-Wins (LWW) vs. CRDTs
When multiple users edit the same record offline, sync conflicts are inevitable. In a traditional database, we rely on locks or Last-Write-Wins (LWW) where the database overwrites old updates based on timestamps. In a collaborative client-first system, LWW leads to data loss (e.g. User A's changes are completely wiped out by User B's sync event 2 seconds later).
To build reliable local-first systems, we use Conflict-free Replicated Data Types (CRDTs).
How CRDTs Work
CRDTs are specialized data structures (such as arrays, text structures, or objects) designed to merge automatically across multiple nodes without requiring a central coordinator. They guarantee strong eventual consistency: as long as all nodes receive the same set of updates (regardless of order or timing), they will resolve to the exact same state.
- State-based (Pn-Portables): Clients exchange entire state payloads, which are merged recursively.
- Operation-based (Op-based): Clients exchange small mutation operations (e.g., "Insert 'c' at index 4") that are replayed deterministically by peer engines.
3. The Local-First Storage Stack in the Browser
To build local-first apps, you need high-performance local storage. In 2025/2026, the storage landscape consists of:
- IndexedDB: The traditional key-value store built into all browsers. It is highly stable and supports up to hundreds of megabytes, but its API is notoriously clunky and lacks built-in reactive queries.
- Origin Private File System (OPFS): A new standard offering access to a highly optimized, private file system. This allows SQLite engines to run inside WebAssembly at near-native speeds.
- SQLite-Wasm: Allows running an actual relational database engine in the client, enabling full SQL search, indexing, and transactional isolation right inside the browser.
4. Implementation Blueprint: Building a Syncable Store with Yjs
Let's build a functional, collaborative todo store using Yjs (a high-performance CRDT library) paired with y-indexeddb for local persistence, and a sync coordinator.
First, set up our dependencies:
npm install yjs y-indexeddb y-webrtc
Here is the architectural implementation of the local store:
// services/LocalStore.ts
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebrtcProvider } from 'y-webrtc';
export interface TodoItem {
id: string;
text: string;
completed: boolean;
timestamp: number;
}
export class CollaborativeTodoStore {
private doc: Y.Doc;
private yTodos: Y.Map<any>;
private indexeddbProvider: IndexeddbPersistence;
private syncProvider: WebrtcProvider;
private onChangeCallbacks: Set<(todos: TodoItem[]) => void> = new Set();
constructor(roomName: string = 'local-first-todo-room') {
// 1. Initialize the Yjs document (the local state container)
this.doc = new Y.Doc();
// 2. Access a collaborative Map type from the document
this.yTodos = this.doc.getMap('todos');
// 3. Connect local persistence using IndexedDB
// This immediately restores data from disk before connecting to any network
this.indexeddbProvider = new IndexeddbPersistence(roomName, this.doc);
// 4. Connect WebRTC peer-to-peer synchronization
// When online, changes will propagate directly between user browsers
this.syncProvider = new WebrtcProvider(roomName, this.doc, {
signaling: ['wss://signaling.yjs.dev']
});
// 5. Watch for state changes and update UI layers reactively
this.yTodos.observe(() => {
this.emitChanges();
});
// Fire initial trigger when data is loaded from IndexedDB
this.indexeddbProvider.on('synced', () => {
this.emitChanges();
});
}
// Write operation: Writes directly to local Yjs Doc in memory (instant)
public addTodo(text: string) {
const id = Math.random().toString(36).substring(7);
const todo: TodoItem = {
id,
text,
completed: false,
timestamp: Date.now()
};
// Updating the Yjs Map will auto-trigger IndexedDB write and WebRTC sync
this.yTodos.set(id, todo);
}
public toggleTodo(id: string) {
const existing = this.yTodos.get(id);
if (existing) {
this.yTodos.set(id, {
...existing,
completed: !existing.completed
});
}
}
public removeTodo(id: string) {
this.yTodos.delete(id);
}
// Read operation: Retrieve values mapped as standard JS structures
public getTodos(): TodoItem[] {
return Array.from(this.yTodos.values()) as TodoItem[];
}
// Reactive subscription pattern for frontend frameworks (React, Solid)
public subscribe(callback: (todos: TodoItem[]) => void): () => void {
this.onChangeCallbacks.add(callback);
// Initial call
callback(this.getTodos());
// Return unsubscribe function
return () => {
this.onChangeCallbacks.delete(callback);
};
}
private emitChanges() {
const todos = this.getTodos().sort((a, b) => b.timestamp - a.timestamp);
this.onChangeCallbacks.forEach(cb => cb(todos));
}
}
Now, we consume this store inside a React container component:
// components/TodoList.tsx
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import { CollaborativeTodoStore, TodoItem } from '../services/LocalStore';
export default function TodoList() {
// Memoize store instance so it stays persistent across re-renders
const store = useMemo(() => new CollaborativeTodoStore(), []);
const [todos, setTodos] = useState<TodoItem[]>([]);
const [text, setText] = useState<string>('');
useEffect(() => {
// Subscribe to store updates
const unsubscribe = store.subscribe((updatedTodos) => {
setTodos(updatedTodos);
});
return () => unsubscribe();
}, [store]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) return;
store.addTodo(text);
setText('');
};
return (
<div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-xl shadow-lg border border-slate-100">
<h2 className="text-2xl font-bold text-slate-800 mb-6">Local-First Tasks</h2>
<form onSubmit={handleSubmit} className="flex gap-2 mb-6">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="New offline task..."
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<button type="submit" className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
Add
</button>
</form>
<ul className="space-y-3">
{todos.map((todo) => (
<li key={todo.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<span
onClick={() => store.toggleTodo(todo.id)}
className={`flex-1 cursor-pointer select-none ${todo.completed ? 'line-through text-slate-400' : 'text-slate-700'}`}
>
{todo.text}
</span>
<button
onClick={() => store.removeTodo(todo.id)}
className="text-red-500 hover:text-red-700 text-sm"
>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
5. Architectural Pain Points & How to Handle Them
While local-first architecture provides lightning-fast user interactions and native offline capabilities, it introduces unique engineering difficulties:
A. Schema Migrations
In a typical cloud-first application, migrating database tables is done in a controlled server environment. In local-first systems, your database schema might reside on 10,000 active devices, some running software versions from six months ago.
- Solution: Design migrations that execute on database start. Store a schema version table in IndexedDB and apply schema modifiers sequentially (e.g.
v1 -> v2 -> v3) inside the client code.
B. Size Constraints & Partial Sync
You cannot sync a 100GB corporate database onto a mobile browser.
- Solution: Implement partial replication. Define sync profiles that sync metadata first, and only download specific collections or record trees on demand.
C. Security and Authorization Filters
If clients perform replication directly, you must ensure that malicious clients cannot manipulate sync channels to pull unauthorized records.
- Solution: Integrate a syncing proxy gate (like Electric SQL or Fireproof) that reads JWT authentication payloads, authorizing or blocking operations at the channel websocket layer.
By placing local storage at the center of the web stack, local-first web applications create unmatched responsiveness, resilient offline operation, and seamless real-time collaboration.
