DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building an Efficient Local-First Developer Workflow with Offline-First Sync and Conflict Resolution

Building an Efficient Local-First Developer Workflow with Offline-First Sync and Conflict Resolution

Building an Efficient Local-First Developer Workflow with Offline-First Sync and Conflict Resolution

In modern software, developers juggle local experimentation, CI/CD pipelines, and remote collaboration. A local-first workflow emphasizes working primarily offline or on a local device, with eventual synchronization to remote services. This approach reduces friction in flaky networks, speeds up iteration, and provides a robust fallback during outages. This guide shows you how to design and implement a productive local-first workflow for a typical web app using a concrete stack: SQLite for local storage, a drift-tolerant synchronization protocol, and a simple conflict resolution strategy. It includes practical patterns, code snippets, and a step-by-step plan you can adapt to your project.

What you’ll learn

  • How to architect a local-first data model and sync layer
  • How to implement offline-first reads and writes with a local database
  • How to design a resilient, end-to-end synchronization protocol
  • How to handle conflicts deterministically and user-friendly
  • How to test offline-first behavior and simulate network partitions

Thoughtful preparation: choose your data model

  • Scope your data: identify the subset that benefits from local-first behavior. Typically this includes user notes, configurations, ephemeral app state, and cached API responses.
  • Use a single-source-of-truth per device: each device owns its own data, and changes are synchronized to others and the cloud.
  • Design for eventual consistency: accept that remote updates may arrive out of order or concurrently.

Stack overview (example)

  • Local store: SQLite (via an embedded driver) or a lightweight local-first database like SQLite with a simple ORM.
  • Sync layer: a background sync process that negotiates changes with a remote service using a resilient protocol (pull-based, push-based, or hybrid).
  • Remote backend: an API that supports optimistic updates, per-row versioning, and conflict metadata.
  • Timestamping and versioning: use vector clocks or per-record last_modified timestamps plus a per-device replica ID.

Section 1: Data model and local database setup

  • Model design
    • Each record has: id (UUID), type, payload (JSON), last_modified (ISO timestamp), deleted flag, and owner_device_id.
    • Implement a per-record versioning field to help resolve conflicts.
  • Database schema (SQL)
    • Create a table “items” to store domain objects.
    • Create a “changes” table to track local edits for syncing. Code example (SQLite schema):
  • CREATE TABLE items ( id TEXT PRIMARY KEY, type TEXT NOT NULL, payload TEXT NOT NULL, last_modified INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, owner_device_id TEXT NOT NULL );
  • CREATE TABLE changes ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, change_type TEXT NOT NULL, timestamp INTEGER NOT NULL, payload TEXT, FOREIGN KEY(item_id) REFERENCES items(id) );

Section 2: Local read/write primitives

  • Write path
    • On write, upsert into items, set last_modified to now, push a change event into changes.
  • Read path
    • Reads return the latest non-deleted records, respecting a query predicate. Code sketch (TypeScript with a SQLite wrapper):
  • interface Item { id: string; type: string; payload: any; last_modified: number; deleted: boolean; owner_device_id: string; }
  • async function upsertItem(db, item: Item) { await db.run("REPLACE INTO items (id, type, payload, last_modified, deleted, owner_device_id) VALUES (?, ?, ?, ?, ?, ?)", [item.id, item.type, JSON.stringify(item.payload), item.last_modified, item.deleted ? 1 : 0, item.owner_device_id]); await db.run("INSERT INTO changes (item_id, change_type, timestamp, payload) VALUES (?, ?, ?, ?)", [item.id, "upsert", item.last_modified, JSON.stringify(item.payload)]); }
  • async function queryItems(db, whereClause, params) { return db.all("SELECT * FROM items WHERE deleted = 0 AND " + whereClause, params).then(rows => rows.map(r => ({ ...r, payload: JSON.parse(r.payload) }))); }

Section 3: The sync protocol: pull, push, and conflict handling

  • Core ideas
    • Each device maintains a local log of changes.
    • Sync happens in cycles: pull remote changes since last_sync, apply to local store; push local changes that aren’t yet acknowledged by the remote.
    • Solve conflicts deterministically using a "last writer wins" with version/timestamp tie-breakers, augmented by device_id to break ties.
  • Remote API expectations
    • GET /changes?since=TIMESTAMP to fetch remote changes
    • POST /changes with a batch of local changes
    • Each change carries: item_id, change_type, timestamp, payload, origin_device_id
  • Conflict resolution approach
    • If both sides modified the same item since last sync, compare last_modified and origin_device_id.
    • Implement a deterministic rule: the item with the higher last_modified wins; if equal, compare origin_device_id lexicographically.
    • We preserve a conflict log for user awareness if necessary.
  • Sync loop outline
    1. Pull: fetch remote_changes since last_sync.
    2. Apply: for each remote change, merge into local items using the resolution rule.
    3. Push: push unsynced local_changes to remote.
    4. Update last_sync to now.

Code sketch (pseudo-API):

  • async function pullChanges(remote, since) { const remoteChanges = await remote.getChanges(since); for (const c of remoteChanges) applyRemoteChange(c); }
  • function applyRemoteChange(change) { // resolve using local last_modified, etc. const local = await db.getItem(change.item_id); if (!local) upsertItem({ id: change.item_id, type: change.type, payload: JSON.parse(change.payload), last_modified: change.timestamp, deleted: false, owner_device_id: change.origin_device_id }); else { if (change.timestamp > local.last_modified) { // remote is newer upsertItem({ id: change.item_id, type: change.type, payload: JSON.parse(change.payload), last_modified: change.timestamp, deleted: false, owner_device_id: change.origin_device_id }); } else { // local newer, do nothing, or mark that a local change exists to push } } }
  • async function pushChanges(remote) { const changes = await db.getUnsentChanges(); await remote.postChanges(changes); markChangesAsSynced(changes); }

Section 4: Conflict resolution details and UX

  • Deterministic policy
    • Use last_modified as primary comparator; if equal, compare origin_device_id.
    • When conflicts are detected (both sides modified since last_sync), record a conflict object: { item_id, local, remote, resolution, timestamp } for auditing.
  • UX patterns
    • Silent autosync with occasional gala conflicts: present a non-intrusive “conflicts resolved automatically” log.
    • Offer a manual conflict reviewer UI for power users: show a list of items with a mini-diff and allow the user to pick which version to keep.
  • Optional: user-mediated resolution
    • If the app is note-taking or task management, you can surface a compare view and let users choose.

Section 5: Testing offline-first behavior

  • Create deterministic test scaffolding
    • Mock time and network: simulate offline, partial sync, dropouts.
    • Validate end-to-end: local write, go offline, write conflicting remote change, go online, run sync, verify correct final state per policy.
  • Test strategies
    • Unit tests for merge logic with synthetic item histories.
    • Integration tests that run a mock remote server to verify push/pull semantics.
    • Chaos testing: simulate partitions, delays, and reordering of events. Code example: a small unit test outline (pseudo-JS/TS)
  • function testConflictResolution() { // Set up two devices: A and B // Device A creates item X at t1 // Device B also edits item X at t1+1000 // Sync A pulls B’s change; apply merge rule // Assert final state matches last_modified winner }

Section 6: Performance and data considerations

  • Batching and compression
    • Batch changes to reduce network chatter; compress payloads with gzip or similar if sizes grow.
  • Throttling
    • Limit push frequency to avoid overwhelming the remote API; exponential backoff on failures.
  • Storage hygiene
    • Prune old changes after a retention window; consider a tombstone strategy for deletions.
  • Security and privacy
    • Encrypt sensitive payloads at rest; ensure transport uses TLS; authenticate devices with per-user credentials.

Section 7: Practical integration: a minimal runnable example

  • Project layout
    • src/
    • db.ts (SQLite wrapper and data models)
    • sync.ts (sync protocol)
    • api.ts (remote API client for changes)
    • models.ts (Item type definitions)
    • tests/
    • sync.test.ts (conflict resolution tests)
  • Minimal code snippets Code snippet: Item and change models
  • export interface Item { id: string; type: string; payload: any; last_modified: number; deleted: boolean; owner_device_id: string; }
  • export interface ChangeLog { id: number; item_id: string; change_type: string; timestamp: number; payload?: string; synced?: boolean; }

Code snippet: Applying a remote change (simplified)

  • async function applyRemoteChange(change: ChangeLog) { const local = await db.getItem(change.item_id); if (!local) { await upsertItem({ id: change.item_id, type: change.change_type, payload: JSON.parse(change.payload!), last_modified: change.timestamp, deleted: false, owner_device_id: change.origin_device_id }); } else if (change.timestamp > local.last_modified) { await upsertItem({ id: change.item_id, type: change.change_type, payload: JSON.parse(change.payload!), last_modified: change.timestamp, deleted: false, owner_device_id: change.origin_device_id }); } // else current local is newer; may want to mark conflict }

Section 8: Rollout plan and maintenance

  • Start small
    • Pick a feature or module that benefits most from offline work (e.g., notes, drafts).
    • Implement local-first support for that module and test end-to-end.
  • Gradual expansion
    • Extend to more data types and refine conflict handling.
  • Observability
    • Instrument metrics: number of conflicts resolved, push/pull durations, sync success rate.
    • Add user-facing logs for sync events to aid debugging.

Illustration: concept of local-first sync lifecycle

  • Local write creates a new record and logs a change.
  • The background sync pulls remote changes, merges using the deterministic rule, and pushes local changes that are not yet acknowledged.
  • Over time, devices converge to a consistent state, with conflicts resolved deterministically and user data preserved.

What to adapt for your project

  • Choose a suitable local store: SQLite is a solid default; you can also explore IndexedDB for web-only contexts.
  • Decide on a conflict policy that matches your domain: note edits may prefer latest change, while task assignments may require explicit user choice.
  • Ensure the remote API supports change history and per-record versioning to enable robust synchronization.

If you’d like, I can tailor this to your tech stack (React, React Native, Node.js, or a backend language you’re using), provide a concrete codebase skeleton, and a step-by-step migration plan for turning an existing app into a local-first experience.

Would you like a starter repository with a runnable example in your preferred language and framework? If yes, tell me your stack (e.g., React + SQLite via Expo, Node.js backend, or a full-stack setup) and whether you want a notes app, a task manager, or another domain as the example.

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)