skills/local-first/references/architecture-patterns.md

12 KiB

title description tags
Architecture Patterns Decision framework for local-first vs server-based vs hybrid architecture, CQRS patterns, read/write path strategies, and progressive enhancement
architecture
cqrs
read-path
write-path
hybrid
progressive-enhancement
migration
decision-tree

When to Go Local-First

Evaluate these four criteria to determine the right architecture model:

Criteria Server-Based Local-First Hybrid
Offline needs None Must work offline Selective offline support
Latency sensitivity Tolerates round-trip Needs instant response Critical paths need instant
Collaboration Single-user or turns Real-time multi-user Mix of single and multi-user
Data size per client Minimal client state Manageable local dataset Some data fits locally
Conflict tolerance N/A (server is truth) Can handle merge conflicts Selective conflict handling
Development complexity Low High Medium

Go local-first when at least two of these are true:

  1. Users need to work offline or on unreliable networks
  2. UI interactions must feel instant (no loading spinners on common actions)
  3. Multiple users edit the same data concurrently
  4. The working dataset per client fits in browser storage (typically < 500MB)

Stay server-based when:

  • Data freshness from the server is critical on every render
  • Business rules require server-authoritative validation (payments, inventory)
  • The app is content-heavy with minimal interactivity
  • Simple CRUD with reliable connectivity

Architecture Models

Server-Based (Traditional)

All reads and writes go through the server. The client has no persistent local state.

async function fetchTodos(): Promise<Todo[]> {
  const response = await fetch('/api/todos');
  return response.json();
}

async function createTodo(todo: NewTodo): Promise<Todo> {
  const response = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(todo),
  });
  return response.json();
}

Local-First

All reads come from a local database. All writes go to the local database first, then sync to the server in the background.

import { createCollection, createTanStackDB } from '@tanstack/db';
import { ElectricProvider } from '@tanstack/db/electric';

const db = createTanStackDB({ collections: { todos } });
const todos = createCollection<Todo>({
  id: 'todos',
  schema: todoSchema,
  sync: {
    provider: new ElectricProvider({ url: electricUrl, table: 'todos' }),
  },
});

function useTodos() {
  return db.useQuery((q) => q.from('todos').where('completed', '=', false));
}

function useCreateTodo() {
  return (todo: NewTodo) => {
    db.mutate.todos.insert({ id: crypto.randomUUID(), ...todo });
  };
}

Hybrid

Server-first for most features. Local-first for high-value interactions where latency or offline matters.

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function useSettings() {
  return useQuery({
    queryKey: ['settings'],
    queryFn: () => fetch('/api/settings').then((r) => r.json()),
  });
}

function useTodos() {
  return db.useQuery((q) => q.from('todos').orderBy('createdAt', 'desc'));
}

CQRS in Local-First

Local-first architecture naturally implements CQRS (Command Query Responsibility Segregation). Reads and writes follow completely different paths.

Read Path:                        Write Path:
┌────────┐                        ┌────────┐
│   UI   │ ← reads from           │   UI   │ ← user action
└────┬───┘                        └────┬───┘
     │                                 │
┌────▼───┐                        ┌────▼────────┐
│Local DB│                        │ Local Write  │ ← always succeeds
└────────┘                        └────┬─────────┘
     ▲                                 │
     │                            ┌────▼─────┐
     │                            │ Sync Layer│ ← background
     │                            └────┬──────┘
     │                                 │
     │                            ┌────▼──────┐
     │                            │  Server   │
     │                            └────┬──────┘
     │                                 │
     └─────── sync back ──────────────┘

The read path never touches the network. The write path writes locally first, then syncs. The server processes the write and syncs the resolved state back.

Read Path Patterns

Direct Server Fetch

Simplest approach. Every read is a network request.

function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/todos')
      .then((r) => r.json())
      .then(setTodos)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <Spinner />;
  return (
    <ul>
      {todos.map((t) => (
        <TodoItem key={t.id} todo={t} />
      ))}
    </ul>
  );
}

Tradeoff: Simple but shows loading spinners on every navigation. Fails completely offline.

Cache-Then-Network

Show cached data immediately, then update when the network responds.

function TodoList() {
  const { data: todos, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then((r) => r.json()),
    staleTime: 30_000,
  });

  if (isLoading) return <Spinner />;
  return (
    <ul>
      {todos.map((t) => (
        <TodoItem key={t.id} todo={t} />
      ))}
    </ul>
  );
}

Tradeoff: Fast subsequent reads, but first load still blocks. No offline support without persistence plugin.

Local-First Read

Reads always come from the local database. The sync engine keeps it up to date.

function TodoList() {
  const todos = db.useQuery((q) =>
    q.from('todos').where('completed', '=', false).orderBy('createdAt', 'desc'),
  );

  return (
    <ul>
      {todos.map((t) => (
        <TodoItem key={t.id} todo={t} />
      ))}
    </ul>
  );
}

Tradeoff: Always instant, works offline, but requires sync infrastructure. No loading state needed for reads.

Write Path Patterns

Server Mutation

Write goes to the server. UI updates after the server responds.

function useCreateTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (todo: NewTodo) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(todo),
      }).then((r) => r.json()),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

Tradeoff: Simple and correct, but the UI feels sluggish. Button disables during the request. Fails offline.

Optimistic Update (Rollback on Failure)

Assume the write will succeed. Update the UI immediately. Rollback if the server rejects it.

function useCreateTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (todo: NewTodo) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(todo),
      }).then((r) => r.json()),
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previous = queryClient.getQueryData<Todo[]>(['todos']);
      queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
        { id: crypto.randomUUID(), ...newTodo, completed: false },
        ...old,
      ]);
      return { previous };
    },
    onError: (_err, _todo, context) => {
      queryClient.setQueryData(['todos'], context?.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

Tradeoff: Feels instant but has complexity around rollback. Still fails offline.

Local-First Write

Write to the local database. It always succeeds. Sync delivers it to the server in the background.

function useCreateTodo() {
  return (todo: NewTodo) => {
    db.mutate.todos.insert({
      id: crypto.randomUUID(),
      ...todo,
      completed: false,
      createdAt: new Date().toISOString(),
    });
  };
}

async function syncTodoWrite(todo: Todo) {
  await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(todo),
  });
}

Tradeoff: Always instant, works offline, but the server may reject the write later. Conflict resolution is needed.

Progressive Enhancement Strategy

Start server-first. Add local-first incrementally for high-value interactions.

Step 1: Identify candidates. Audit your app for interactions where latency or offline matters most.

Interaction Latency Sensitive Offline Needed Local-First Candidate
Todo CRUD Yes Yes Yes
User settings No No No
Chat messages Yes Yes Yes
Payment checkout No No No
Document editing Yes Yes Yes
Admin dashboard No No No

Step 2: Add local-first to one feature. Keep everything else server-based.

const todos = createCollection<Todo>({
  id: 'todos',
  schema: todoSchema,
  sync: {
    provider: new ElectricProvider({ url: electricUrl, table: 'todos' }),
  },
});

// Settings still uses server-based approach
function useSettings() {
  return useQuery({
    queryKey: ['settings'],
    queryFn: fetchSettings,
  });
}

Step 3: Expand gradually. Move more features to local-first as confidence grows.

Data Model Considerations

Normalized Tables

Best for relational queries. Works well with SQL-based sync engines (ElectricSQL, PowerSync).

CREATE TABLE projects (
  id UUID PRIMARY KEY,
  name TEXT NOT NULL,
  owner_id UUID REFERENCES users(id)
);

CREATE TABLE todos (
  id UUID PRIMARY KEY,
  project_id UUID REFERENCES projects(id),
  title TEXT NOT NULL,
  completed BOOLEAN DEFAULT false
);

Document Store

Best for offline-friendly blobs. Works well with document-based sync (Replicache, Triplit).

type TodoDocument = {
  id: string;
  title: string;
  completed: boolean;
  project: {
    id: string;
    name: string;
  };
  tags: string[];
  metadata: Record<string, unknown>;
};

Hybrid Approach

Normalized tables for relational data. Embedded documents for self-contained entities.

CREATE TABLE todos (
  id UUID PRIMARY KEY,
  project_id UUID REFERENCES projects(id),
  title TEXT NOT NULL,
  completed BOOLEAN DEFAULT false,
  metadata JSONB DEFAULT '{}'
);

Migration Path: Server-Based to Local-First

Evolve incrementally without rewriting your app.

Phase 1: Add a local cache layer. Use TanStack Query with persistence.

import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { persistQueryClient } from '@tanstack/react-query-persist-client';

const persister = createSyncStoragePersister({
  storage: window.localStorage,
});

persistQueryClient({ queryClient, persister });

Phase 2: Introduce a local database for high-value data.

const todos = createCollection<Todo>({
  id: 'todos',
  schema: todoSchema,
  sync: {
    provider: new ElectricProvider({ url: electricUrl, table: 'todos' }),
  },
});

Phase 3: Move writes to local-first for synced collections.

// Before: server mutation
const mutation = useMutation({
  mutationFn: (todo: NewTodo) => api.createTodo(todo),
});

// After: local-first write
function createTodo(todo: NewTodo) {
  db.mutate.todos.insert({ id: crypto.randomUUID(), ...todo });
}

Phase 4: Remove server-fetch code for synced data. The local database is now the source of truth. Server fetches are replaced by sync.