Classic web application architecture (Cloud-First) is constructed on the premise that a centralized remote server is the single source of truth, while the client browser serves merely as an ephemeral display terminal. Every user action (typing a keystroke, moving a kanban card, checking a checkbox) requires a network roundtrip to an API endpoint before confirming changes in the user interface. Under fluctuating cellular connections, airplane flights, or infrastructure outages, this paradigm introduces blocked inputs, loading spinners, and catastrophic loss of unsaved work.
The Local-First Software paradigm fundamentally reimagines these mechanics:
The foundation of modern Local-First architectures relies on CRDTs (Conflict-free Replicated Data Types) and high-throughput browser storage primitives—OPFS and IndexedDB.
In Local-First architectures, the central server ceases to act as an authoritarian transaction arbiter. The client itself is a self-contained database. Business logic executes locally with 0 ms latency, and inter-peer synchronization is reduced to exchanging compact binary deltas generated by mathematical CRDT structures.
In traditional collaborative applications, merging edits from multiple users required row-level database locks or complex Operational Transformation (OT, as used in legacy Google Docs), which depend strictly on a centralized coordination server.
In CRDT architectures, state reconciliation is grounded in the mathematical theory of join-semilattices, where the state merge function merge(A, B) satisfies three fundamental axioms:
merge(A, B) = merge(B, A) (network packet delivery order does not alter the converged outcome).merge(merge(A, B), C) = merge(A, merge(B, C)) (deltas can be batched and partitioned arbitrarily without divergence).merge(A, A) = A (replaying duplicate network packets during retries is inherently safe and produces zero side effects).┌────────────────────────────────────────────────────────────────────────┐
│ LOCAL-FIRST REPLICATION VIA CRDT │
│ │
│ User A (Offline on a train) User B (Connected in office)│
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ React 19 UI │ │ React 19 UI │ │
│ │ (Instant Input) │ │ (Instant Input) │ │
│ └──────────┬───────────┘ └──────────┬───────────┘ │
│ │ 0 ms latency │ 0 ms latency │
│ ▼ ▼ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Local CRDT Document │ │ Local CRDT Document │ │
│ │ (Yjs / Y.Doc) │ │ (Yjs / Y.Doc) │ │
│ ├──────────────────────┤ ├──────────────────────┤ │
│ │ IndexedDB / OPFS │ │ IndexedDB / OPFS │ │
│ │ (Persistence Layer) │ │ (Persistence Layer) │ │
│ └──────────┬───────────┘ └──────────┬───────────┘ │
│ │ │ │
│ │ (Reconnected: binary delta) │ (Sync Protocol)│
│ └───────────────────► ┌─────────────┐ ◄────┘ │
│ │ Relay Server│ │
│ │ (WebSocket) │ │
│ └─────────────┘ │
│ Automatic │
│ Conflict-Free Convergence! │
└────────────────────────────────────────────────────────────────────────┘The Yjs framework implements the YATA algorithm—a high-performance CRDT designed for text, nested arrays, and JSON maps, where each operation is represented as an immutable item tagged with an identifier ID(Client_ID, Clock):
User mutates a Y.Map or Y.Text. Yjs commits a block with logical timestamps in 0 ms.
The doc.on('update') event is captured by the y-indexeddb provider, persisting binary deltas to browser storage.
When connecting to the network, the client computes its known clocks and requests missing remote operations.
The WebSocket relay exchanges only the missing binary delta chunks between peers.
The useSyncExternalStore hook detects Y.Doc updates and triggers a tearing-free concurrent re-render.
Which mathematical property of the merge(A, B) operation guarantees that all client replicas reach identical states regardless of network packet delivery order?
Integrating Yjs into React 19 requires the native useSyncExternalStore hook to prevent visual tearing under Concurrent Mode:
'use client';
import * as React from 'react';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebsocketProvider } from 'y-websocket';
export interface UserAwarenessState {
user: {
id: string;
name: string;
color: string;
};
cursor?: { x: number; y: number } | null;
}
export interface UseLocalFirstDocOptions {
docId: string;
websocketUrl?: string;
currentUser: UserAwarenessState['user'];
}
export interface UseLocalFirstDocReturn<T extends Record<string, unknown>> {
doc: Y.Doc;
data: T;
updateData: (mutator: (map: Y.Map<unknown>) => void) => void;
isSyncedLocal: boolean;
isSyncedRemote: boolean;
collaborators: Array<{ clientId: number; state: UserAwarenessState }>;
updateCursor: (cursor: { x: number; y: number } | null) => void;
}
export function useLocalFirstDoc<T extends Record<string, unknown>>(
options: UseLocalFirstDocOptions
): UseLocalFirstDocReturn<T> {
const { docId, websocketUrl = 'wss://sync.agenticlab.dev/yjs', currentUser } = options;
// Stable long-lived Y.Doc instance bound to the docId
const [doc] = React.useState(() => new Y.Doc());
const ymap = React.useMemo(() => doc.getMap('data'), [doc]);
const [isSyncedLocal, setIsSyncedLocal] = React.useState(false);
const [isSyncedRemote, setIsSyncedRemote] = React.useState(false);
const [collaborators, setCollaborators] = React.useState<
Array<{ clientId: number; state: UserAwarenessState }>
>([]);
const wsProviderRef = React.useRef<WebsocketProvider | null>(null);
// 1. Initialize local IndexedDB and remote WebSocket sync providers
React.useEffect(() => {
const idbProvider = new IndexeddbPersistence(docId, doc);
idbProvider.on('synced', () => {
setIsSyncedLocal(true);
});
const wsProvider = new WebsocketProvider(websocketUrl, docId, doc, {
connect: true,
});
wsProviderRef.current = wsProvider;
wsProvider.on('status', (event: { status: 'connected' | 'disconnected' | 'connecting' }) => {
setIsSyncedRemote(event.status === 'connected');
});
// 2. Set up live presence via the Awareness Protocol
wsProvider.awareness.setLocalStateField('user', currentUser);
const handleAwarenessChange = () => {
const states = wsProvider.awareness.getStates();
const activeCollaborators: Array<{ clientId: number; state: UserAwarenessState }> = [];
states.forEach((state, clientId) => {
if (clientId !== doc.clientID && (state as UserAwarenessState).user) {
activeCollaborators.push({
clientId,
state: state as UserAwarenessState,
});
}
});
setCollaborators(activeCollaborators);
};
wsProvider.awareness.on('change', handleAwarenessChange);
return () => {
wsProvider.awareness.off('change', handleAwarenessChange);
wsProvider.destroy();
idbProvider.destroy();
doc.destroy();
};
}, [docId, doc, websocketUrl, currentUser]);
// 3. React 19 subscription via useSyncExternalStore for tearing-free renders
const subscribe = React.useCallback(
(onStoreChange: () => void) => {
ymap.observe(onStoreChange);
return () => {
ymap.unobserve(onStoreChange);
};
},
[ymap]
);
const getSnapshot = React.useCallback((): string => {
return JSON.stringify(ymap.toJSON());
}, [ymap]);
const rawJson = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const data = React.useMemo(() => JSON.parse(rawJson) as T, [rawJson]);
// Atomic local document mutator executed inside a single transaction
const updateData = React.useCallback(
(mutator: (map: Y.Map<unknown>) => void) => {
doc.transact(() => {
mutator(ymap);
});
},
[doc, ymap]
);
const updateCursor = React.useCallback((cursor: { x: number; y: number } | null) => {
wsProviderRef.current?.awareness.setLocalStateField('cursor', cursor);
}, []);
return {
doc,
data,
updateData,
isSyncedLocal,
isSyncedRemote,
collaborators,
updateCursor,
};
}User mutates Y.Map. Yjs records a local operation block tagged with logical clocks.
The doc.on('update') hook triggers y-indexeddb, writing binary deltas to storage.
Client exchanges State Vectors. The relay server returns only missing operations via binary buffers.
Yjs reconciles inbound bytes, emitting updates to useSyncExternalStore without visual tearing.
Why must multiple property modifications in Yjs be wrapped in doc.transact(() => ...) rather than executing independent ymap.set() calls?
Here is a collaborative Kanban board component demonstrating local-first persistence, live user presence indicators, and instant 0 ms drag-and-drop operations:
'use client';
import * as React from 'react';
import { useLocalFirstDoc } from '@/shared/hooks/useLocalFirstDoc';
interface Task {
id: string;
title: string;
status: 'todo' | 'in_progress' | 'done';
}
interface BoardData {
tasks?: Record<string, Task>;
}
export function KanbanBoard({ boardId }: { boardId: string }) {
const currentUser = React.useMemo(
() => ({
id: `user-${Math.random().toString(36).substring(2, 7)}`,
name: 'Frontend Architect',
color: '#8B5CF6',
}),
[]
);
const { data, updateData, isSyncedLocal, isSyncedRemote, collaborators } =
useLocalFirstDoc<BoardData>({
docId: `board-${boardId}`,
currentUser,
});
const tasksList = React.useMemo(() => {
return Object.values(data.tasks ?? {});
}, [data.tasks]);
const handleAddTask = () => {
const newTaskId = `task-${Date.now()}`;
const newTask: Task = {
id: newTaskId,
title: `Task #${tasksList.length + 1}`,
status: 'todo',
};
// Transaction executes synchronously with 0 ms UI delay!
updateData((map) => {
const currentTasks = (map.get('tasks') as Record<string, Task>) || {};
map.set('tasks', { ...currentTasks, [newTaskId]: newTask });
});
};
const handleMoveTask = (taskId: string, newStatus: Task['status']) => {
updateData((map) => {
const currentTasks = (map.get('tasks') as Record<string, Task>) || {};
const target = currentTasks[taskId];
if (target) {
map.set('tasks', {
...currentTasks,
[taskId]: { ...target, status: newStatus },
});
}
});
};
return (
<div className="p-6 rounded-2xl bg-bg-surface border border-border space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4 pb-4 border-b border-border">
<div>
<h2 className="text-xl font-bold text-text-primary">Local-First Workspace</h2>
<div className="flex items-center gap-3 text-xs mt-1">
<span className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${isSyncedLocal ? 'bg-emerald-400' : 'bg-yellow-400'}`} />
{isSyncedLocal ? 'Disk Persisted' : 'Saving...'}
</span>
<span className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${isSyncedRemote ? 'bg-emerald-400' : 'bg-red-400'}`} />
{isSyncedRemote ? 'Cloud Online' : 'Offline Mode'}
</span>
</div>
</div>
{/* Live Collaborator Presence */}
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">In document:</span>
<div className="flex -space-x-2">
<div
className="w-7 h-7 rounded-full border-2 border-bg-surface flex items-center justify-center text-[10px] font-bold text-white"
style={{ backgroundColor: currentUser.color }}
title="You"
>
Me
</div>
{collaborators.map((c) => (
<div
key={c.clientId}
className="w-7 h-7 rounded-full border-2 border-bg-surface flex items-center justify-center text-[10px] font-bold text-white"
style={{ backgroundColor: c.state.user.color }}
title={c.state.user.name}
>
{c.state.user.name[0]}
</div>
))}
</div>
<button
type="button"
onClick={handleAddTask}
className="ml-4 px-3 py-1.5 bg-accent-violet text-white text-xs font-semibold rounded-lg hover:bg-accent-violet/90 transition-colors"
>
+ Add Task Card
</button>
</div>
</div>
{/* Task Columns */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(['todo', 'in_progress', 'done'] as const).map((statusKey) => (
<div key={statusKey} className="p-4 rounded-xl bg-bg-elevated/40 border border-border/60">
<h4 className="text-xs font-semibold uppercase tracking-wider text-text-secondary mb-3">
{statusKey.replace('_', ' ')}
</h4>
<div className="space-y-2">
{tasksList
.filter((t) => t.status === statusKey)
.map((task) => (
<div
key={task.id}
className="p-3 rounded-lg bg-bg-surface border border-border shadow-sm flex items-center justify-between"
>
<span className="text-sm font-medium text-text-primary">{task.title}</span>
{statusKey !== 'done' && (
<button
type="button"
onClick={() =>
handleMoveTask(
task.id,
statusKey === 'todo' ? 'in_progress' : 'done'
)
}
className="text-xs text-accent-violet hover:underline ml-2"
>
Next →
</button>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}Modifying Yjs structures inside loops without doc.transact() emits distinct update events and independent network messages for every single attribute. This floods React renders and overwhelms network sockets. Always wrap related mutations in a single transaction.
// ❌ ANTI-PATTERN: Emits dozens of network frames and re-renders
items.forEach((item) => {
ymap.set(item.id, item); // Each call triggers an independent sync packet!
});
// ✅ RECOMMENDED: Atomic transaction in a single batch
doc.transact(() => {
items.forEach((item) => {
ymap.set(item.id, item);
});
}); // Produces exactly one combined delta!Subscribing to ymap.observe(() => setState(...)) via useState inside useEffect causes visual tearing in React 19 Concurrent Mode, where different parts of the screen render inconsistent versions of the same document. Integration with external CRDT state must always use useSyncExternalStore.
CRDTs maintain an append-only metadata graph (Tombstones: deleted items are marked, not erased, to preserve convergence). Placing Base64 images or PDFs directly into Y.Doc balloons document sizes to gigabytes. Always store large binaries in OPFS or S3, embedding only their content hash (SHA-256) and URL in the CRDT document.
While IndexedDB APIs are asynchronous, serializing and deserializing massive object graphs (>50 MB) blocks the JavaScript main thread. For heavy workloads (PGlite, SQLite), run database engines inside Web Workers backed by OPFS FileSystemSyncAccessHandle.
useSyncExternalStore to eliminate visual tearing under Concurrent Mode.doc.transact(), and protect disk persistence with navigator.storage.persist().