React 19Local-FirstOfflinePGLiteReplicache

Local-First React: The Death of the Loading Spinner 💀

O
Offline Systems Engineer
Featured Guide 45 min read

Optimistic UI is not enough.

We've all written the code: setShow(true), then fetch('/api/create'). If the fetch fails, we revert the UI.

But what if the user is offline for 3 days? What if they close the tab before the fetch completes? What if they make 50 edits while in a tunnel?

Local-First is not just "caching." It means the Client Database is the source of truth for the UI. The Server is just a backup.

Deep Dive: The 100ms Threshold

Jakob Nielsen's rule: < 100ms feels instantaneous.
Server roundtrips vary (50ms - 500ms). Local-First guarantees < 10ms for all read/write operations by hitting the local DB first.

02. Sync Engine Architecture

In a Local-First app, you never `await fetch()` in your component's event handler.

// ❌ Traditional Flow
Click -> Await API -> Update UI -> Error if Offline

// ✅ Local-First Flow
Click -> Write to Local DB (IndexedDB/Wasm) -> Update UI Instantly -> Background Sync Process -> Server

The Sync Queue

Your application needs a robust queueing system.

  • Mutation: A user action (e.g., "Add Todo") is serializable into a JSON instruction.
  • Persist: Save this instruction to IndexedDB immediately.
  • Apply: Run the logic against the local state for instant feedback.
  • Push: A `navigator.onLine` listener attempts to flush the queue to the server.
  • Rebase: If the server rejects it (conflict), re-calculate the state.

03. Conflict-Free Replicated Data Types (CRDTs)

When two users edit the same document offline, and then come online, who wins?
CRDTs are data structures that guarantee mathematical consistency.

Last-Write-Wins (LWW)

Simple, brute force.

User A sets title "Hello" at 10:00.
User B sets title "Hi" at 10:01.
Sync -> Title is "Hi".

Grow-Only Set (G-Set)

Perfect for Todo Lists.

You can only ADD items. Removing is strictly "Adding a tombstone". Merging two lists is just the union of both.

04. The Modern Stack

1

Replicache

Commercial grade sync engine. Handles the queue, the websocket, and the optimistic UI for you. Used by linear.app style apps.

2

PGLite (Postgres in Wasm)

Run a full Postgres database inside the browser tab. Sync it to a real server-side Postgres via logical replication.

3

ElectricSQL

An open-source layer that sits between your Postgres and your clients, managing active replication streams.

06. Build: Offline-First Todo

Try this simulation. Turn "Off" the network switch. Add items. Reload the page (simulating a crash). Then turn the network back "On" and watch the sync happen.

Interactive Playground

import React, { useState, useEffect, useRef } from 'react';

// 💾 Local-First Todo Simulator
// Demonstrates the "Write Local -> Sync Background" pattern

export default function SyncEngineDemo() {
    const [online, setOnline] = useState(true);
    const [todos, setTodos] = useState([]); // Local Store
    const [serverTodos, setServerTodos] = useState([]); // Remote Store (Mock)
    const [pendingMutations, setPendingMutations] = useState(0);
    const [isSyncing, setIsSyncing] = useState(false);

    // Initial Load (Simulate hydrating from LocalStorage)
    useEffect(() => {
        const saved = localStorage.getItem('local-todos');
        if (saved) setTodos(JSON.parse(saved));
        
        // Mock initial server state
        setServerTodos([
            { id: 1, text: 'Buy Milk', synced: true },
            { id: 2, text: 'Walk Dog', synced: true }
        ]);
        
        if (!saved) {
             setTodos([
                { id: 1, text: 'Buy Milk', synced: true },
                { id: 2, text: 'Walk Dog', synced: true }
            ]);
        }
    }, []);

    // Persist to LocalStorage on every change (Persistence Layer)
    useEffect(() => {
        localStorage.setItem('local-todos', JSON.stringify(todos));
        const pending = todos.filter(t => !t.synced).length;
        setPendingMutations(pending);
    }, [todos]);

    // The Sync Loop
    useEffect(() => {
        if (!online) return;

        const interval = setInterval(() => {
            const pending = todos.filter(t => !t.synced);
            if (pending.length > 0) {
                syncData(pending);
            }
        }, 3000); // Try to sync every 3s if online

        return () => clearInterval(interval);
    }, [online, todos]);

    const syncData = async (pendingItems) => {
        setIsSyncing(true);
        // Simulate Network Latency
        await new Promise(r => setTimeout(r, 1000));

        // updating "Server"
        const newServerTodos = [...serverTodos];
        pendingItems.forEach(item => {
            if (!newServerTodos.find(i => i.id === item.id)) {
                newServerTodos.push({ ...item, synced: true });
            }
        });
        setServerTodos(newServerTodos);

        // Updating "Client" to mark as synced
        setTodos(prev => prev.map(t => ({...t, synced: true})));
        setIsSyncing(false);
    };

    const addTodo = (e) => {
        e.preventDefault();
        const text = e.target.input.value;
        if (!text) return;

        const newTodo = {
            id: Date.now(),
            text,
            synced: false // Initially false!
        };

        // 1. Optimistic Update (Instant)
        setTodos(prev => [...prev, newTodo]);
        e.target.reset();
    };

    return (
        <div className="flex flex-col md:flex-row gap-8 min-h-[500px]">
            
            {/* CLIENT DEVICE */}
            <div className="flex-1 bg-white dark:bg-slate-900 rounded-3xl border-4 border-slate-200 dark:border-slate-800 p-2 shadow-2xl relative overflow-hidden">
                <div className="absolute top-0 w-full h-6 bg-slate-200 dark:bg-slate-800 rounded-t-xl flex justify-center items-center gap-2 z-10">
                    <div className="w-16 h-4 bg-black rounded-full"></div>
                </div>
                
                <div className="pt-8 px-4 pb-4 h-full flex flex-col">
                    <div className="flex justify-between items-center mb-6">
                        <h3 className="font-bold text-xl">My Tasks</h3>
                        <button 
                            onClick={() => setOnline(!online)}
                            className={`p-2 rounded-full transition-colors ${online ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}`}
                        >
                            {online ? <span>📶</span> : <span>📵</span>}
                        </button>
                    </div>

                    <div className="flex-1 overflow-y-auto space-y-2">
                        {todos.map(t => (
                            <div key={t.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-800/50 rounded-xl border border-gray-100 dark:border-slate-700">
                                <span>{t.text}</span>
                                {t.synced ? (
                                    <span></span>
                                ) : (
                                    <span className="animate-spin inline-block"></span>
                                )}
                            </div>
                        ))}
                    </div>

                    <div className="mt-4 pt-4 border-t border-gray-100 dark:border-slate-800">
                         <form onSubmit={addTodo} className="flex gap-2">
                             <input name="input" placeholder="New Task..." className="flex-1 bg-gray-100 dark:bg-slate-800 rounded-lg px-4 py-2 text-sm outline-none focus:ring-2 ring-rose-500" />
                             <button className="bg-rose-600 text-white rounded-lg px-4 font-bold">+</button>
                         </form>
                         <div className="text-[10px] text-gray-400 mt-2 text-center">
                             Stats: {pendingMutations} Pending Sync | Status: {online ? 'Online' : 'Offline'}
                         </div>
                    </div>
                </div>
            </div>

            {/* SYNC CLOUD */}
            <div className="flex-1 flex flex-col items-center justify-center p-8 bg-slate-50 dark:bg-black/20 rounded-3xl border border-dashed border-slate-300 dark:border-slate-700 relative">
                 <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
                     {isSyncing && (
                         <div className="w-full h-1 bg-gradient-to-r from-transparent via-rose-500 to-transparent animate-[shimmer_1s_infinite]"></div>
                     )}
                 </div>

                 <div className="mb-8 p-6 bg-white dark:bg-slate-800 rounded-full shadow-xl border border-slate-200 dark:border-slate-700 z-10">
                     <span className={`text-6xl ${isSyncing ? "animate-pulse" : ""}`}>🗄️</span>
                 </div>

                 <h3 className="text-xl font-bold mb-4 text-slate-500">Cloud Database (Postgres)</h3>
                 
                 <div className="w-full max-w-xs bg-slate-900 text-slate-300 p-4 rounded-xl font-mono text-xs overflow-hidden">
                     <div className="text-slate-500 mb-2 border-b border-slate-800 pb-2">SELECT * FROM todos;</div>
                     {serverTodos.map(t => (
                         <div key={t.id} className="truncate text-green-400">
                             {t.id}: "{t.text}"
                         </div>
                     ))}
                     {serverTodos.length === 0 && <span className="text-slate-600">Empty...</span>}
                 </div>
            </div>
        </div>
    );
}