Skip to content

Offline-First with Conflict Resolution

advanced18 min read

The Local-First Philosophy

Most web apps treat the server as the source of truth. Your device is a dumb terminal that fetches data, displays it, and sends changes back. If the server is unreachable, the app is useless.

Offline-first flips this: the device is the source of truth. Data lives locally. The UI reads from local storage. Changes are written locally first. Sync with the server happens in the background, when possible.

This isn't just about handling airplane mode. It's about fundamentally better UX:

  • Instant responsiveness — every read and write is local, no network round trip
  • Works everywhere — elevators, tunnels, spotty conference WiFi, airplane, subway
  • Survives server outages — your server goes down, users don't even notice
  • Reduces server load — the server handles sync, not every read/write

The challenge? When two devices edit the same data offline and then sync, you need conflict resolution.

Mental Model

Think of offline-first like writing in a personal notebook that periodically syncs with a shared Google Doc. You always have your notebook. You can write anytime. When you get WiFi, your notes merge into the shared doc. If your colleague wrote in the same section while you were offline, you need to reconcile — but the point is, you were never blocked from working.

IndexedDB as Primary Datastore

For offline-first web apps, IndexedDB is your database. Not a cache. Not a fallback. The primary datastore.

interface SyncableRecord {
  id: string;
  data: unknown;
  updatedAt: number;
  version: number;
  syncStatus: 'synced' | 'pending' | 'conflict';
  localChanges?: unknown;
}

class LocalStore {
  private db: IDBDatabase | null = null;

  async open(name: string, stores: string[]): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(name, 1);
      request.onupgradeneeded = () => {
        const db = request.result;
        for (const store of stores) {
          if (!db.objectStoreNames.contains(store)) {
            const objectStore = db.createObjectStore(store, { keyPath: 'id' });
            objectStore.createIndex('syncStatus', 'syncStatus');
            objectStore.createIndex('updatedAt', 'updatedAt');
          }
        }
      };
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };
      request.onerror = () => reject(request.error);
    });
  }

  async put(store: string, record: SyncableRecord): Promise<void> {
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(store, 'readwrite');
      tx.objectStore(store).put(record);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }

  async get(store: string, id: string): Promise<SyncableRecord | undefined> {
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(store, 'readonly');
      const request = tx.objectStore(store).get(id);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  async getPending(store: string): Promise<SyncableRecord[]> {
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(store, 'readonly');
      const index = tx.objectStore(store).index('syncStatus');
      const request = index.getAll('pending');
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}
Quiz
Why should IndexedDB be the primary datastore in an offline-first app, not just a cache?

Sync Protocols: Full State vs Delta

When the device comes back online, it needs to sync with the server. Two approaches:

Full State Sync

Send the entire local state. Server compares with its state, computes a merge, and returns the result.

async function fullStateSync(
  localStore: LocalStore,
  storeName: string,
  serverUrl: string
): Promise<void> {
  const tx = localStore.db!.transaction(storeName, 'readonly');
  const allRecords: SyncableRecord[] = await new Promise((resolve, reject) => {
    const request = tx.objectStore(storeName).getAll();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });

  const response = await fetch(`${serverUrl}/sync/full`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ records: allRecords }),
  });

  const { merged } = await response.json();
  for (const record of merged) {
    await localStore.put(storeName, { ...record, syncStatus: 'synced' });
  }
}
  • Pros: Simple, always correct, handles any level of divergence
  • Cons: Expensive for large datasets, wastes bandwidth on unchanged data

Delta Sync

Track what changed since the last sync. Only send and receive changes.

interface SyncCheckpoint {
  lastSyncTimestamp: number;
  lastServerVersion: number;
}

async function deltaSync(
  localStore: LocalStore,
  storeName: string,
  serverUrl: string,
  checkpoint: SyncCheckpoint
): Promise<SyncCheckpoint> {
  const pendingRecords = await localStore.getPending(storeName);

  const response = await fetch(`${serverUrl}/sync/delta`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      changes: pendingRecords,
      since: checkpoint.lastServerVersion,
    }),
  });

  const { serverChanges, newVersion, conflicts } = await response.json();

  for (const change of serverChanges) {
    const local = await localStore.get(storeName, change.id);
    if (local?.syncStatus === 'pending') {
      // Conflict: local change + server change
      await localStore.put(storeName, {
        ...local,
        syncStatus: 'conflict',
        localChanges: local.data,
        data: change.data,
      });
    } else {
      await localStore.put(storeName, { ...change, syncStatus: 'synced' });
    }
  }

  for (const record of pendingRecords) {
    if (!conflicts.includes(record.id)) {
      await localStore.put(storeName, { ...record, syncStatus: 'synced' });
    }
  }

  return {
    lastSyncTimestamp: Date.now(),
    lastServerVersion: newVersion,
  };
}
StrategyFull State SyncDelta Sync
BandwidthHigh (sends everything)Low (sends only changes)
Server complexityLow (stateless comparison)Medium (track versions, compute diffs)
Client complexityLowMedium (track sync checkpoints)
Recovery from corruptionAutomatic (full state is self-healing)May need full sync fallback
First syncWorks perfectlyNeeds initial full sync
Long offline periodAlways correctMay miss intermediate changes depending on server retention

In practice, use delta sync as the default with full state sync as a fallback for first connection, long offline periods, or suspected data corruption.

Quiz
When should an offline-first app fall back from delta sync to full state sync?

Conflict Resolution Beyond Last-Writer-Wins

LWW is the simplest conflict resolution: the write with the latest timestamp wins. It works when:

  • Conflicts are rare
  • The data is not critical (user preferences, draft notes)
  • Users expect "my latest change is what matters"

But LWW silently discards data. When Alice edits a task title while Bob edits the same task title offline, one of them loses their work. For many applications, this is unacceptable.

Field-Level Merge

Instead of LWW on the entire record, apply LWW per field:

interface FieldChange {
  field: string;
  value: unknown;
  timestamp: number;
}

function fieldLevelMerge(
  base: Record<string, unknown>,
  local: Record<string, FieldChange>,
  remote: Record<string, FieldChange>
): Record<string, unknown> {
  const result = { ...base };

  const allFields = new Set([...Object.keys(local), ...Object.keys(remote)]);

  for (const field of allFields) {
    const localChange = local[field];
    const remoteChange = remote[field];

    if (localChange && !remoteChange) {
      result[field] = localChange.value;
    } else if (!localChange && remoteChange) {
      result[field] = remoteChange.value;
    } else if (localChange && remoteChange) {
      result[field] = localChange.timestamp >= remoteChange.timestamp
        ? localChange.value
        : remoteChange.value;
    }
  }

  return result;
}

Alice changes the title, Bob changes the description — both changes survive. Only same-field concurrent edits need a tiebreaker.

Three-Way Merge

The gold standard for structured data. Compare local and remote states against a common ancestor (base) to detect exactly what each side changed:

type MergeResult =
  | { type: 'clean'; value: unknown }
  | { type: 'conflict'; base: unknown; local: unknown; remote: unknown };

function threeWayMerge(
  base: Record<string, unknown>,
  local: Record<string, unknown>,
  remote: Record<string, unknown>
): Map<string, MergeResult> {
  const results = new Map<string, MergeResult>();
  const allKeys = new Set([
    ...Object.keys(base),
    ...Object.keys(local),
    ...Object.keys(remote),
  ]);

  for (const key of allKeys) {
    const b = base[key];
    const l = local[key];
    const r = remote[key];

    const localChanged = !deepEqual(b, l);
    const remoteChanged = !deepEqual(b, r);

    if (!localChanged && !remoteChanged) {
      results.set(key, { type: 'clean', value: b });
    } else if (localChanged && !remoteChanged) {
      results.set(key, { type: 'clean', value: l });
    } else if (!localChanged && remoteChanged) {
      results.set(key, { type: 'clean', value: r });
    } else if (deepEqual(l, r)) {
      results.set(key, { type: 'clean', value: l });
    } else {
      results.set(key, { type: 'conflict', base: b, local: l, remote: r });
    }
  }

  return results;
}

function deepEqual(a: unknown, b: unknown): boolean {
  return JSON.stringify(a) === JSON.stringify(b);
}

Three-way merge gives you clean merges for the majority of cases and explicitly surfaces true conflicts — cases where both sides changed the same field to different values.

Quiz
In a three-way merge, what happens when only the local side changed a field?

Eventual Consistency UX Patterns

The hardest part of offline-first isn't the sync protocol — it's communicating sync state to the user.

Optimistic UI with Sync Status

Show changes immediately. Indicate sync status subtly:

type SyncIndicator = 'saved-locally' | 'syncing' | 'synced' | 'conflict' | 'error';

interface UIRecord {
  data: unknown;
  syncStatus: SyncIndicator;
  lastSyncedAt: number | null;
  pendingSince: number | null;
}

Visual patterns that work:

  • Cloud icon with checkmark: Synced
  • Cloud icon with arrows: Syncing
  • Cloud icon with offline symbol: Saved locally, will sync when online
  • Warning triangle: Conflict needs attention
Key Rules
  1. 1Never block the UI on network operations — write locally first, sync in background
  2. 2Always show sync status — users need to know if their changes have been synced
  3. 3Surface conflicts explicitly — don't silently discard data
  4. 4Keep the common ancestor (base state) for three-way merge — without it you can only do LWW
  5. 5Implement a full-state sync fallback for when delta sync can't recover

Conflict Resolution UX

When three-way merge detects a true conflict, you have options:

  1. Automatic resolution with policy — LWW, or prefer-local, or prefer-remote. Silent but may lose data.
  2. Show both versions — Let the user choose. Best for important data but interrupts workflow.
  3. Merge and highlight — Auto-merge what you can, highlight conflicting fields. Best balance.
function renderConflict(
  field: string,
  result: MergeResult & { type: 'conflict' }
): ConflictUI {
  return {
    field,
    message: `This field was changed on both devices`,
    yourVersion: result.local,
    otherVersion: result.remote,
    originalVersion: result.base,
    actions: [
      { label: 'Keep yours', value: result.local },
      { label: 'Keep theirs', value: result.remote },
      { label: 'Keep both', value: mergeValues(result.local, result.remote) },
    ],
  };
}

Lessons From Production: Linear and Notion

Linear's Offline Mode

Linear is the gold standard for offline-first in a SaaS product. Their approach:

  • Local SQLite (via WASM) as the primary datastore
  • Delta sync with a server-maintained operation log
  • Optimistic mutations — UI updates instantly, syncs in background
  • Conflict resolution — field-level merge with LWW per field
  • Partial sync — only sync data the user has access to (team/project scoping)

The key insight: Linear treats the server as a sync peer, not the source of truth. The client doesn't "ask permission" to make changes — it makes them locally and tells the server afterward.

Notion's Local-First Journey

Notion has been progressively moving toward local-first:

  • Block-level CRDTs for document structure
  • Local persistence for recently viewed pages
  • Offline editing for pages that are cached locally
  • Sync on reconnection with conflict detection

Their challenge is scale: a Notion workspace can have millions of blocks. You can't sync everything locally. The solution is partial replication — only sync the subset of data the user actively needs.

The "local-first software" movement, formalized by Ink and Switch's 2019 paper, defines seven ideals: (1) no spinners — reads are instant, (2) your work is not trapped on one device, (3) the network is optional, (4) seamless collaboration, (5) the Long Now — data outlives the service, (6) security and privacy by default, (7) user retains ownership and control. No existing product achieves all seven perfectly, but the direction is clear — the industry is moving toward local-first.

Quiz
What is the key architectural insight behind Linear's offline-first approach?

Building the Sync Engine

Putting it all together — a minimal but complete sync engine:

class SyncEngine {
  private syncTimer: ReturnType<typeof setInterval> | null = null;
  private isSyncing = false;

  constructor(
    private localStore: LocalStore,
    private storeName: string,
    private serverUrl: string,
    private checkpoint: SyncCheckpoint = { lastSyncTimestamp: 0, lastServerVersion: 0 }
  ) {}

  start(intervalMs = 5000): void {
    this.sync();
    this.syncTimer = setInterval(() => this.sync(), intervalMs);

    window.addEventListener('online', () => this.sync());
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') this.sync();
    });
  }

  async write(id: string, data: unknown): Promise<void> {
    const existing = await this.localStore.get(this.storeName, id);
    await this.localStore.put(this.storeName, {
      id,
      data,
      updatedAt: Date.now(),
      version: (existing?.version ?? 0) + 1,
      syncStatus: 'pending',
    });
  }

  private async sync(): Promise<void> {
    // Quick pre-check only — navigator.onLine is unreliable (see reconnection-and-offline-queueing)
    if (this.isSyncing || !navigator.onLine) return;
    this.isSyncing = true;

    try {
      this.checkpoint = await deltaSync(
        this.localStore,
        this.storeName,
        this.serverUrl,
        this.checkpoint
      );
    } catch {
      // Sync failed — will retry on next interval
    } finally {
      this.isSyncing = false;
    }
  }

  stop(): void {
    if (this.syncTimer) clearInterval(this.syncTimer);
  }
}
What developers doWhat they should do
Treating the server as the source of truth with offline as a fallback
If the server is the source of truth, every operation requires a round trip. Offline becomes an afterthought with poor UX. Local-first means instant reads and writes always, with sync as a background optimization.
Treat the local device as the source of truth with the server as a sync peer
Using last-writer-wins for all conflict resolution
LWW silently discards one side's changes. For a task where Alice edited the title and Bob edited the title differently, LWW throws away one edit with no trace. Three-way merge detects this and can surface it for resolution.
Use three-way merge for structured data, surface true conflicts to the user
Syncing the entire dataset to every client
A workspace with millions of records can't fit on a mobile device. Partial sync reduces storage, bandwidth, and initial sync time. Sync recently accessed data eagerly, and fetch older data on demand.
Use partial replication — only sync data the user needs (scope by team, project, recent access)
Not keeping the common ancestor for three-way merge
Without the base, you can't distinguish 'both changed from X to different values' from 'both changed from different values to the same value.' Three-way merge requires the ancestor to determine what actually changed.
Store the base version (last synced state) alongside current local state
Interview Question

System Design: Offline-First Task Manager

Design a task management app (like Linear) that works fully offline. Users can create tasks, update status, assign members, and add comments while offline. Cover: local storage schema (IndexedDB tables and indexes), sync protocol (what happens when two users change the same task's status offline), conflict resolution strategy for each field type (title: text, status: enum, assignee: reference, comments: list), and the UX for showing sync state. Bonus: how do you handle task deletion while another user is adding a comment to it?