0306090120150180210240270300330km/h
0 km/h
ADIT JANGID

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.

The Rise of Local-First Web Applications: Architecture & Tools

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   |
+------------------+                    +-------------------+
  1. 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.
  2. Offline-Capable by Design: The application retains full read/write functionality whether the device is in a subway tunnel or on an airplane.
  3. 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.

local-firstarchitecturecrdtindexeddbdatabase

Related Posts

Interested in working together?

Let's translate complex software and automation problems into clean, high-performance systems.