A practical look at local-first state, synchronization, conflicts, trust, and product boundaries through the lessons of Tract.
Local-first is a product promise
Tract gave us a useful lens for local-first product design: the user’s work should remain available and responsive on the device, while synchronization extends that work across devices or collaborators. This is different from adding a cache to a server-dependent application. The local copy is not an expendable acceleration layer; it participates in the product’s durable state and recovery story.
That promise needs a boundary. Not every feature can function without a network, and not every dataset belongs on every device. Define which reading, creation, editing, search, and export operations work locally; which require a server; how long credentials remain useful; and what happens when a device is lost. “Offline supported” is too vague to design or test.
Model local state as durable, inspectable data
A robust client needs more than current records. It may need stable entity identifiers, local operation identifiers, actor and device identity, schema version, logical ordering, pending changes, synchronization checkpoints, deletion markers, and conflict state. Persist enough intent to retry safely after a crash without guessing whether a change reached the server.
- Generate collision-resistant identifiers without waiting for a server round trip.
- Make local operations idempotent so replay after interruption does not duplicate effects.
- Treat deletion as information that must synchronize, not an immediate absence that peers cannot explain.
- Version the local schema and test upgrades from realistically old application versions.
- Protect sensitive local data with platform-appropriate encryption, credential handling, and remote revocation assumptions.
Synchronization is a protocol, not a background fetch
Sync must answer what changed, since when, according to whom, and in which order. Wall-clock timestamps alone are unreliable across devices and reconnects. A server-issued cursor, append-only operation sequence, entity version, or another monotonic mechanism can make progress and replay explicit. Compaction may be necessary, but it should preserve the information required for convergence and audit.
- Record local intent
Commit the user’s operation and its identifier locally before presenting it as safely saved.
- Exchange from checkpoints
Push pending operations and pull authoritative changes from a resumable cursor rather than transferring the whole dataset.
- Apply deterministic rules
Validate permissions and versions, deduplicate operations, and merge only where the domain has a defensible merge function.
- Advance after durable apply
Move the checkpoint only after changes are committed, so interruption can replay without creating gaps.
- Surface unresolved state
Keep conflicts and rejected operations available for a person or domain-specific recovery flow.
Conflicts are product decisions
Last-write-wins is simple, but it can silently discard a thoughtful edit because another device’s clock or reconnect happened later. Automatic merging works for some sets, counters, or independent fields, but can violate invariants for schedules, approvals, or ordered content. Choose conflict semantics by domain object and operation, not one global rule.
When human resolution is required, show the meaningful difference and its origin. Asking a user to choose between two opaque JSON versions transfers architecture complexity to them. Preserve both inputs, explain which fields or actions conflict, and offer a safe preview. In collaborative products, presence and early warnings can prevent conflicts, but they should not be required for correctness.
Make synchronization trustworthy and test hostile timelines
Users need simple language for local, pending, synchronized, conflicted, and rejected state. A green cloud icon is not enough if it does not distinguish “queued on this device” from “accepted by the server.” Provide last successful sync, relevant pending work, a retry path, and export or support recovery for serious failures. Avoid blocking normal local work because unrelated synchronization is slow.
Test with reordered and duplicated messages, long offline periods, expired credentials, schema upgrades, low storage, interrupted transactions, deleted accounts, revoked access, and several devices editing the same records. Property-based tests can check convergence and idempotency; scenario tests can validate product behavior. The main Tract lesson is that local-first quality comes from designing time and uncertainty explicitly, not from treating the network as a request that occasionally fails.