There’s a moment in building an offline-capable app when the problem reveals its true size. Mine came while testing ScrumBoard’s PWA on a phone in airplane mode: I moved a card, closed the app, reopened it on my laptop, moved the same card somewhere else, then reconnected the phone.

What should happen?

Answering that question honestly forced me to admit what I’d actually built. An app that queues writes while offline isn’t “an app with a loading spinner removed.” It’s a distributed system with an untrusted, intermittently-connected replica — the user’s device — that accepts writes with no coordination, then reconciles later. Every fundamental from this series’ opening articles applies at once: ambiguous failures, duplicate delivery, concurrent updates, eventual consistency. The browser is just where they all moved in together.

This article is the sync design that came out of that realization: a queue in IndexedDB, client-generated IDs, an idempotent replay protocol, and a deliberately boring conflict policy. It works — the same protocol has survived real phones on real networks — and every piece traces back to a principle you can verify.

The Intellectual Frame: Local-First

The definitive statement of this problem space is “Local-first software: you own your data, in spite of the cloud” — the 2019 essay by Martin Kleppmann and the Ink & Switch research lab, published academically at ACM’s Onward! conference. Their first ideal is the one users actually feel, and they named it memorably: “no spinners” — work happens “at the speed of your device, not at the speed of your network.” Their fuller list of seven ideals includes multi-device support, full offline capability, and seamless collaboration without conflicts.

The essay’s technical bet is on CRDTs — conflict-free replicated data types, structures that mathematically guarantee concurrent edits merge to one consistent result, in any order, without coordination. Kleppmann’s group built Automerge to make them practical, and the CRDT.tech catalog tracks the family.

Here’s where I’ll be honest about the gap between the research frontier and a working app: ScrumBoard does not use CRDTs. It uses something dumber, and I’ll defend that choice below — but the local-first framing still earns its place, because it correctly identifies the architecture: the client owns a full working copy; the server is a sync point, not a gatekeeper. Once you accept that, the design questions become concrete: what’s the unit of sync, how do you replay safely, and who wins a conflict?

The Unit of Sync: Operations, Not State

The first design fork: when the client reconnects, does it upload its state (“here’s my whole board”) or its operations (“I moved card 12, then renamed card 7”)?

State upload seems simpler and is a trap: it turns every reconnect into a potential bulldozer. Whichever device syncs last overwrites everything the other changed — including changes to cards the offline device never touched. Operations carry intent, and intent is mergeable at fine grain: your rename of card 7 and my move of card 12 both survive, even though our board states disagree.

So the client-side core is an operation log in IndexedDB (the only browser storage that’s async, transactional, and sized for real data). Each entry, at minimum:

{
  opId:      "uuid-generated-on-this-device",   // identity — the load-bearing field
  kind:      "card.move",
  payload:   { cardId: "c12", toColumn: "doing", position: 3 },
  baseRev:   41,          // board revision this op was made against
  createdAt: 1722816000
}

The UI applies operations locally, immediately — no spinners — and appends them to the queue. A sync loop drains the queue whenever connectivity allows.

The load-bearing field is opId, and the load-bearing decision is that the client generates it. This is the idempotency-key pattern with the key born at the true origin of the operation. It has to be — the whole point is that the operation exists before the server ever hears about it.

The Replay Protocol: Assume Everything Happens Twice

The sync loop’s failure modes are exactly the ambiguous-timeout menu from earlier in the series: the batch may fail to arrive, arrive and fail midway, or succeed and the ack gets lost — in which case the client, knowing nothing, sends the same batch again. Duplicates aren’t an edge case in this design; they’re the steady state after any bad network day. The server therefore treats every incoming operation as possibly-already-seen:

func (s *SyncService) ApplyBatch(ctx context.Context, boardID string, ops []Op) (BatchResult, error) {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return BatchResult{}, err
    }
    defer tx.Rollback()

    var results []OpResult
    for _, op := range ops {
        // Dedup and effect share ONE transaction: the dual-write lesson.
        res, err := tx.ExecContext(ctx,
            `INSERT INTO applied_ops (op_id, board_id) VALUES ($1, $2)
             ON CONFLICT (op_id) DO NOTHING`, op.ID, boardID)
        if err != nil {
            return BatchResult{}, err
        }
        if n, _ := res.RowsAffected(); n == 0 {
            results = append(results, OpResult{ID: op.ID, Status: "duplicate"})
            continue // already applied on a previous (acked-or-not) attempt
        }
        if err := s.applyOp(ctx, tx, boardID, op); err != nil {
            results = append(results, OpResult{ID: op.ID, Status: "rejected", Reason: err.Error()})
            continue // rejection is an ANSWER, not an error — client dequeues it
        }
        results = append(results, OpResult{ID: op.ID, Status: "applied"})
    }
    if err := tx.Commit(); err != nil {
        return BatchResult{}, err
    }
    return BatchResult{Results: results}, nil
}

Three properties worth noticing. Per-operation results: a batch is not all-or-nothing — op 3 being rejected (its card was deleted by another device) must not block ops 4–9. “Duplicate” is a success status: the client dequeues it exactly like “applied,” which is what makes lost acks harmless. The client’s contract: remove an op from IndexedDB only when the server has answered for it — any answer. Retries with backoff and jitter handle the rest.

Ordering gets one guarantee: operations from one device apply in that device’s order (the queue drains sequentially). Cross-device order is decided by arrival — which brings us to the part everyone asks about first.

Conflicts: I Chose Last-Write-Wins, On Purpose

Two devices move the same card while offline. The research answer is CRDTs; ScrumBoard’s answer is last-write-wins at the field level, plus rejection when the target is gone — the airplane-mode scenario resolves as “whichever device syncs second wins the card’s position.”

This looks like the coward’s option next to the CRDT literature, so here’s the actual reasoning. CRDTs shine when the merge itself carries value — collaborative text, where losing either person’s sentence is unacceptable. For a kanban card’s position, the merge-vs-overwrite difference is two moves resolving to… one of the two positions the users chose. A user seeing their move superseded by a teammate’s more recent one reads it as “someone else moved it” — which is true — not as data loss. The cost of Automerge (payload size, library complexity, a data model reshaped around the CRDT) bought approximately nothing for this domain.

The honest counterpoint: this trade re-runs differently for other products, and the local-first movement’s warning is legitimate — LWW silently discards writes, and in domains where each write is precious (documents, notes, anything long-form), silent discard is data loss with good PR. The Ink & Switch essay is the right lens: ask which of the seven ideals your product actually owes its users, and pay the machinery cost for those, not for the checklist.

When NOT to Build Offline-First

This architecture is a commitment, and most apps shouldn’t make it:

The Build Order That Worked

  1. Client-generated UUIDs on every mutation — even before any offline capability. It costs nothing and makes every later step possible.
  2. The op queue in IndexedDB, drained by a sync loop with backoff. UI applies ops locally first.
  3. The server’s dedup-in-transaction handler with per-op results. This is the same ~40 lines shown above, adapted to your domain.
  4. A revision number per synced entity (baseRev), so the server can distinguish “stale but harmless” from “target no longer exists.”
  5. Conflict policy as a product decision, written down. LWW, field-level merge, or CRDT — chosen per entity, with the silent-discard cost stated where a product owner can veto it.
  6. Test the ugly reconnects: duplicate batch delivery, batch with one poisoned op, two devices interleaving, ack lost after full apply. These four scripts found every real bug I’ve had in this system.

The airplane-mode question that opened this article turned out to have a boring answer — second sync wins, the queue replays clean, nobody loses work they’d recognize as theirs. Getting to boring took every pattern this series has covered so far. Next up: what happens when a single business action must span multiple services’ databases — sagas, and the art of compensation.

References