8 min read

Building Local-First Web Apps with CRDTs: The Complete Yjs & IndexedDB Architecture

Building Local-First Web Apps with CRDTs: The Complete Yjs & IndexedDB Architecture

For the past fifteen years, the software industry has operated under a single dominant architecture: the centralized cloud client-server model. Your application's source of truth resides on a remote database server, while the client (browser or mobile app) acts as a thin presentation layer that spends its lifecycle waiting for HTTP requests to resolve.

To mask the latency of this round-trip architecture, frontend developers have spent thousands of hours engineering workarounds: loading skeletons, spinner overlays, and optimistic UI mutations that frequently roll back when a train enters a tunnel or mobile signal drops.

Local-First Software, a paradigm formalized by Martin Kleppmann and the researchers at Ink & Switch, fundamentally reverses this relationship. In a local-first application, the primary source of truth lives locally on the user's device (in IndexedDB or SQLite via OPFS). Network synchronization happens asynchronously in the background as an optional transport layer.

In this guide, we break down the mathematical principles of Conflict-free Replicated Data Types (CRDTs), implement a resilient local-first sync pipeline using Yjs and IndexedDB, and examine how to handle real-world peer-to-peer collaboration.


The Core Principles of Local-First Software

A local-first application adheres to seven foundational ideals:

  1. Zero-Latency Reads and Writes: Every interaction (clicking, typing, reordering) mutates local disk storage instantly. Users never see a loading spinner.
  2. Multi-Device Seamless Synchronization: Work created on a laptop synchronizes seamlessly to a smartphone or desktop when connectivity is available.
  3. Network Optional: The application is 100% operational in complete airplane mode without degraded functionality.
  4. Collaboration by Default: Two or more users can concurrently edit the same document without overwriting each other's contributions.
  5. Data Longevity: If the company providing the cloud synchronization server goes bankrupt or shuts down, the user's data remains fully accessible on their local disk forever.
[Traditional Cloud App vs Local-First Architecture]

  Traditional Cloud Architecture (Central Source of Truth)
  User Interaction ──► [Wait...] ──► Remote API Server ──► PostgreSQL
                           ▲
                    Network Flake = Broken UI

  Local-First Architecture (Local Source of Truth)
  User Interaction ──► Local Disk (IndexedDB / SQLite OPFS) ──► Instant 0ms Render
                             │
                             ▼ (Background Async Transport)
                      CRDT State Sync Layer (P2P WebRTC / WebSocket Relay)

Advertisement

The Mathematics of CRDTs: Why Merge Order Doesn't Matter

The primary engineering obstacle in distributed, offline systems is conflict resolution. If User A edits paragraph one while offline on a flight, and User B edits the same paragraph while offline in an office, how does the system merge their edits when both reconnect?

Traditional algorithms like Operational Transformation (OT)—used in early Google Docs—require a centralized, authoritative server to sequence all operations linearly. If the central server is unreachable, collaboration ceases.

Conflict-free Replicated Data Types (CRDTs) are mathematical structures designed to be replicated across multiple distributed nodes without centralized coordination. They satisfy three core mathematical properties:

  1. Commutativity: A \cdot B = B \cdot A (The order in which updates are received does not matter).
  2. Associativity: (A \cdot B) \cdot C = A \cdot (B \cdot C) (The grouping of incoming packets does not affect the outcome).
  3. Idempotence: A \cdot A = A (Applying the same update multiple times produces the identical result, eliminating duplicate packet bugs).

Whether node updates arrive in order, out of order, or are duplicated over an unstable network, every client is mathematically guaranteed to converge to the exact same state.


Production Implementation with Yjs and IndexedDB

Yjs is the highest-performance CRDT library in the JavaScript ecosystem. It represents documents as an internal linked list of state vectors, achieving memory footprints and execution speeds orders of magnitude faster than early JSON CRDTs.

Step 1: Install Dependencies

npm install yjs y-indexeddb y-webrtc y-websocket

Step 2: Initialize the Local Document & Persistence Layer

We initialize a Y.Doc and bind it immediately to the browser's IndexedDB storage using y-indexeddb. All state loads from local disk in milliseconds:

// local-store.ts
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebrtcProvider } from 'y-webrtc';
import { WebsocketProvider } from 'y-websocket';

export interface TaskItem {
  id: string;
  title: string;
  completed: boolean;
  updatedAt: number;
}

export class LocalFirstTaskStore {
  doc: Y.Doc;
  tasksMap: Y.Map<TaskItem>;
  persistence: IndexeddbPersistence;
  webrtcProvider: WebrtcProvider | null = null;
  wsProvider: WebsocketProvider | null = null;

  constructor(boardId: string) {
    // 1. Instantiate the root CRDT Document
    this.doc = new Y.Doc();

    // 2. Bind to local IndexedDB (Persistence First)
    this.persistence = new IndexeddbPersistence(`kanban-board-${boardId}`, this.doc);

    // 3. Define shared state maps
    this.tasksMap = this.doc.getMap<TaskItem>('tasks');

    this.persistence.on('synced', () => {
      console.log('Local IndexedDB loaded into memory successfully!');
    });

    // 4. Initialize Multi-Transport Network Providers
    this.initNetworkSync(boardId);
  }

  private initNetworkSync(boardId: string) {
    // P2P WebRTC Mesh: Direct browser-to-browser syncing over local LAN/STUN
    this.webrtcProvider = new WebrtcProvider(`room-${boardId}`, this.doc, {
      signaling: ['wss://signaling.yjs.dev', 'wss://y-webrtc-signaling-eu.herokuapp.com'],
    });

    // Central WebSocket Relay fallback (for reliable cross-firewall sync)
    this.wsProvider = new WebsocketProvider(
      'wss://demos.yjs.dev',
      `room-${boardId}`,
      this.doc
    );
  }

  // --- CRUD Operations (All 100% Synchronous and Zero-Latency) ---

  addTask(id: string, title: string) {
    this.doc.transact(() => {
      this.tasksMap.set(id, {
        id,
        title,
        completed: false,
        updatedAt: Date.now(),
      });
    });
  }

  toggleTask(id: string) {
    const existing = this.tasksMap.get(id);
    if (!existing) return;

    this.doc.transact(() => {
      this.tasksMap.set(id, {
        ...existing,
        completed: !existing.completed,
        updatedAt: Date.now(),
      });
    });
  }

  deleteTask(id: string) {
    this.tasksMap.delete(id);
  }

  subscribe(callback: (tasks: TaskItem[]) => void) {
    const observer = () => {
      const items = Array.from(this.tasksMap.values());
      callback(items);
    };

    this.tasksMap.observe(observer);
    // Initial emission
    observer();

    return () => {
      this.tasksMap.unobserve(observer);
    };
  }

  destroy() {
    this.webrtcProvider?.destroy();
    this.wsProvider?.destroy();
    this.persistence.destroy();
    this.doc.destroy();
  }
}

Integrating with React 19

Consuming this store inside React components requires zero fetch requests. We subscribe directly to the local CRDT observer:

'use client';

import { useEffect, useState, useMemo } from 'react';
import { LocalFirstTaskStore, TaskItem } from './local-store';

export function KanbanBoard({ boardId }: { boardId: string }) {
  const [tasks, setTasks] = useState<TaskItem[]>([]);
  const [inputTitle, setInputTitle] = useState('');

  const store = useMemo(() => new LocalFirstTaskStore(boardId), [boardId]);

  useEffect(() => {
    const unsubscribe = store.subscribe((updatedTasks) => {
      setTasks(updatedTasks);
    });

    return () => {
      unsubscribe();
      store.destroy();
    };
  }, [store]);

  function handleCreate(e: React.FormEvent) {
    e.preventDefault();
    if (!inputTitle.trim()) return;

    store.addTask(crypto.randomUUID(), inputTitle.trim());
    setInputTitle('');
  }

  return (
    <div className="p-6 max-w-xl mx-auto space-y-4">
      <h1 className="text-2xl font-bold">Offline-First Tasks</h1>

      <form onSubmit={handleCreate} className="flex gap-2">
        <input
          type="text"
          value={inputTitle}
          onChange={(e) => setInputTitle(e.target.value)}
          placeholder="New task (works offline)..."
          className="flex-1 rounded border px-3 py-2 text-sm"
        />
        <button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white text-sm font-medium">
          Add Task
        </button>
      </form>

      <ul className="divide-y border rounded-xl overflow-hidden bg-white dark:bg-gray-900">
        {tasks.map((task) => (
          <li key={task.id} className="flex items-center justify-between p-3">
            <span className={task.completed ? 'line-through text-gray-400' : ''}>
              {task.title}
            </span>
            <div className="flex gap-2">
              <button
                onClick={() => store.toggleTask(task.id)}
                className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800"
              >
                {task.completed ? 'Undo' : 'Done'}
              </button>
              <button
                onClick={() => store.deleteTask(task.id)}
                className="text-xs px-2 py-1 rounded bg-red-50 text-red-600"
              >
                Delete
              </button>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

Advertisement

Production Realities & Engineering Tradeoffs

While local-first delivers the ultimate user experience, it presents unique architectural tradeoffs:

  1. Storage Limits: Mobile Safari historically caps IndexedDB at 1GB unless granted explicit permission by the user. For media-heavy apps, store binaries (photos, video) in cloud object storage (S3) and keep CRDT metadata references local.
  2. Schema Evolution & Migrations: In traditional cloud databases, running prisma migrate updates the single central schema. In local-first, thousands of client devices might be running code versions from six months ago. CRDT schema changes must always be strictly backward-compatible.
  3. End-to-End Encryption: Because sync relays merely route binary CRDT state vectors, you can easily encrypt data payloads on the client with Web Crypto (AES-GCM-256) before transmission. The sync server routes opaque encrypted blobs without ever reading user data.

Frequently Asked Questions

What is the difference between Yjs and Automerge?

Both are industry-leading CRDT implementations. Yjs is written in JavaScript and optimized heavily for real-time collaborative text editing and low memory usage. Automerge is implemented in Rust (with WebAssembly bindings) and focuses on rich JSON document structures, time-travel versioning, and formal cryptographic proofs.

Can local-first apps work with traditional relational databases?

Yes! Hybrid architectures use tools like ElectricSQL, PowerSync, or Zero (by Rocicorp). These tools run an SQLite engine locally on the client and continuously stream bidirectional sync deltas to a central PostgreSQL database.

How does Yjs prevent state vectors from growing infinitely?

CRDTs track operation histories (tombstones). If users delete 10,000 items, naive CRDTs retain deletion tombstones forever. Yjs implements State Vector Compaction—when all active peers have acknowledged a state snapshot, tombstones are garbage-collected and consolidated into a compact binary representation.


You Might Also Like

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement