Pavan Rangani

HomeBlogLocal-First Software Architecture: Building Apps That Work Offline and Sync

Local-First Software Architecture: Building Apps That Work Offline and Sync

By Pavan Rangani · March 16, 2026 · Architecture

Local-First Software Architecture: Building Apps That Work Offline and Sync

Local-First Software Architecture

Local-first software architecture is a design philosophy where the application stores data on the user’s device as the primary copy, with cloud sync as a secondary concern. The app works fully offline, syncs when connected, and resolves conflicts automatically using CRDTs or operational transforms. Crucially, this inverts the usual assumption that the network is always available and the server is always authoritative.

This approach has gained massive momentum in 2026 with tools like Electric SQL, PowerSync, and Automerge reaching production maturity. Major applications like Linear, Figma, and Notion have proven that local-first can deliver superior user experiences — instant interactions, zero loading spinners, and true offline capability. Moreover, the term itself traces back to the influential 2019 essay by Ink & Switch, which laid out seven ideals these tools now chase.

Why Local-First Matters Now

Traditional client-server architecture treats the server as the source of truth. Every user action requires a network round trip. This creates latency, loading states, and complete failure when offline. Moreover, it puts all data under the control of the service provider.

Local-first software architecture diagram
Local-first architecture: data lives on device, syncs to cloud

Local-first flips this model. Data lives on the device first. Mutations are instant — they modify local state and sync in the background. Consequently, the UI never shows loading spinners for user-initiated actions. The app works identically whether connected, on slow WiFi, or completely offline.

Traditional Architecture:
User Action → Network Request → Server Processing → Response → UI Update
Latency: 100-500ms, Fails offline

Local-First Architecture:
User Action → Local State Update → UI Update (instant)
                    └─► Background Sync → Cloud
Latency: <1ms, Works offline

Core Building Blocks

CRDTs: Conflict-Free Replicated Data Types

CRDTs are data structures that can be modified independently on multiple devices and merged automatically without conflicts. They are the mathematical foundation of local-first sync. Specifically, every operation is designed to be commutative, associative, and idempotent, which guarantees that any two replicas applying the same set of operations in any order converge to the same state.

// Using Automerge — a CRDT library for JavaScript
import { next as Automerge } from "@automerge/automerge";

// Initialize a document
let doc = Automerge.init();
doc = Automerge.change(doc, "Create board", (d) => {
  d.title = "Sprint 42";
  d.columns = {

    inProgress: { name: "In Progress", tasks: [] },
    done: { name: "Done", tasks: [] },
  };
});

// Device A adds a task (offline)
let docA = Automerge.change(doc, "Add task", (d) => {
  d.columns.
    id: "task-1",
    title: "Fix login bug",
    assignee: "alice",
  });
});

// Device B adds a different task (also offline)
let docB = Automerge.change(doc, "Add task", (d) => {
  d.columns.
    id: "task-2",
    title: "Update docs",
    assignee: "bob",
  });
});

// When devices reconnect — merge automatically, no conflicts
let merged = Automerge.merge(docA, docB);
// merged.columns.

Choosing a CRDT Flavor

Not every CRDT suits every problem, and picking the wrong one bloats storage or loses intent. For example, a counter that only ever increments is best modeled as a grow-only counter (G-Counter), whereas a counter that can decrement needs a PN-Counter that tracks increments and decrements separately. Similarly, a set whose elements can be removed and re-added requires an Observed-Remove Set (OR-Set) so that a concurrent add wins over a stale remove.

Sequence types, which back collaborative text editors, are the hardest. Therefore, libraries like Automerge and Yjs implement specialized list CRDTs (RGA and YATA respectively) that assign each character a stable position identifier, so concurrent insertions interleave deterministically instead of corrupting the document. As a practical matter, benchmarks show Yjs tends to use less memory for large text documents, while Automerge offers richer history and time-travel. Consequently, the right choice depends on whether you value compact size or auditable history.

Sync Engines: The Missing Middleware

Building raw CRDT sync from scratch is complex. Sync engines handle the hard parts — network transport, change detection, batching, authentication, and retry logic:

// Using Electric SQL — Postgres sync to local SQLite
import { electrify } from "electric-sql/wa-sqlite";
import { schema } from "./generated/client";

// Initialize local SQLite database synced with Postgres
const electric = await electrify(db, schema, {
  url: "https://api.myapp.com/electric",
  auth: { token: authToken },
});

// Sync specific tables with shape subscriptions
await electric.sync.subscribe({
  shape: {
    tables: ["tasks", "projects"],
    where: { team_id: currentTeam.id },
  },
});

// Reads are always local — instant, works offline
const tasks = await db.tasks.findMany({
  where: { status: "
  orderBy: { priority: "desc" },
});

// Writes are local-first — instant, syncs in background
await db.tasks.create({
  data: {
    title: "New task",
    status: "
    project_id: project.id,
  },
});
// UI updates immediately, sync happens automatically
Sync engine architecture for local-first apps
Sync engines handle the complexity of bidirectional data synchronization

The "shape" concept above deserves emphasis because it solves a real scaling problem. You almost never want a device to hold the entire database; a single user only needs their team's rows. Therefore, shape subscriptions act as partial-replication filters, streaming just the subset that matches a predicate and keeping it live. As the underlying Postgres rows change, the engine pushes deltas down so the local SQLite copy stays current without a full re-sync.

Production Architecture Pattern

A production local-first system typically has four layers:

┌──────────────────────────────────────────────────┐
│                  UI Layer                         │
│  React/Vue/Swift — reads from local store         │
├──────────────────────────────────────────────────┤
│              Local Store Layer                    │
│  SQLite/IndexedDB/OPFS — source of truth          │
├──────────────────────────────────────────────────┤
│              Sync Engine Layer                    │
│  Electric/PowerSync/Automerge — bidirectional sync│
├──────────────────────────────────────────────────┤
│              Cloud Layer                          │
│  PostgreSQL/Supabase — backup, sharing, auth      │
└──────────────────────────────────────────────────┘

Conflict Resolution Strategies

Not all conflicts can be resolved automatically by CRDTs. For domain-specific conflicts, implement custom resolution logic:

// Custom conflict resolver for calendar events
function resolveConflict(local: Event, remote: Event): Event {
  // Last-write-wins for simple fields
  const resolved = { ...local };

  // Domain logic: if both changed the time, keep the earlier one
  if (local.startTime !== remote.startTime) {
    resolved.startTime = local.updatedAt > remote.updatedAt
      ? local.startTime
      : remote.startTime;
  }

  // Domain logic: attendee lists merge (union)
  resolved.attendees = [...new Set([
    ...local.attendees,
    ...remote.attendees,
  ])];

  // Flag for user review if title conflicts
  if (local.title !== remote.title) {
    resolved.conflicted = true;
    resolved.conflictVersions = [local, remote];
  }

  return resolved;
}

Notice that the resolver mixes three strategies on purpose. Last-write-wins is acceptable for low-stakes scalar fields, set union preserves intent for additive collections, and an explicit conflict flag escalates genuinely ambiguous edits to the user. As a result, the system stays automatic where it safely can and human-in-the-loop where it must, which is the pragmatic middle ground most products actually need.

The Authorization and Security Gap

A subtle but critical challenge is that local-first weakens the traditional security boundary. In a server-authoritative app, you validate permissions on every request before mutating data. By contrast, a local-first app applies the write immediately and only checks authorization when the change reaches the server during sync. Therefore, a malicious client can fabricate operations that look valid locally.

The correct mitigation is to treat the server as the authority for acceptance even though the device is the authority for latency. Concretely, the sync endpoint must re-validate every incoming operation against row-level security rules, and it must be prepared to reject and roll back a change the client already showed as applied. Consequently, your UI needs to handle the rare "this write was rejected after the fact" case gracefully, typically by reverting the optimistic state and surfacing a clear message. Skipping this step is one of the most common ways teams ship an insecure offline product.

When NOT to Go Local-First

Local-first adds significant complexity. Therefore, avoid it when: your app is inherently real-time collaborative with no offline use case, your data is too large to store locally (video editing, large datasets), you need strict sequential consistency (financial transactions, inventory), or your team lacks experience with distributed systems concepts.

There are also subtler costs worth naming. Encrypted local databases complicate compliance with data-deletion requests, since you must guarantee a tombstone propagates to every device that ever held a copy. Additionally, debugging becomes harder because a bug may only manifest after a specific interleaving of offline edits, which is difficult to reproduce. In short, local-first trades network failure modes for distributed-systems failure modes, and you should only make that trade when offline-first behavior is a genuine product requirement rather than a resume-driven choice.

Team building local-first applications
Evaluating whether local-first architecture fits your application requirements

Key Takeaways

Local-first architecture delivers instant user experiences, true offline support, and data ownership. The ecosystem has matured significantly in 2026 with production-ready tools. Start with a single data model, prove the sync pattern works, and expand gradually. As a result, your users get an app that feels impossibly fast and never fails due to network issues.

Related Reading

External Resources

In conclusion, local-first software architecture is an essential topic for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.

← Back to all articles