Skip to content
Data Modelstable

Offline-first sync and conflict model

Records that live on the device and reconcile with a server: collision-resistant IDs, client/server versioning with base_version conflict detection, field-level merge with custom resolvers, server-enforced scope, idempotent failure handling, and paginated delta-sync for scale.

When the device holds the authoritative copy until it can sync, the data model has to survive concurrent edits, crashes mid-write, deletes that must propagate, records moving in and out of scope, and datasets too large to move at once. This spec defines identity, versioning, conflict resolution, scope, failure handling, and scale so two devices editing the same record converge on the same result and no acknowledged change is silently dropped.

Conformance language follows RFC 2119: MUST / MUST NOT are enforceable requirements, SHOULD / SHOULD NOT are strong recommendations with legitimate exceptions, and MAY marks a genuine choice. The convergence and no-loss properties hold under the assumptions stated here — collision-resistant IDs, a single authoritative server ordering writes, and the conflict rules below.

Entity model

Entity-relationship model

Every syncable record carries a globally-unique ID, separate client and server versions, timestamps, a soft-delete tombstone, and a dirty flag marking local changes not yet pushed. A per-device sync checkpoint records how far the last successful sync reached.

Offline sync ERD
Offline sync ERD

Definition

Identity

Record IDs are generated on the client and MUST be collision-resistant across devices — UUIDv4/v7 or equivalent, never sequential integers or timestamp-plus-hash — so two devices editing offline do not mint the same ID for different records. (A UUID collision is not literally impossible; it is negligible at any realistic scale, and that negligibility is the property relied on here.)

Definition

Versioning strategy

Each record's version is a monotonically increasing integer per record. Client and server versions are tracked separately: client_version advances on every local edit; server_version is the version the server assigned when it last accepted the record. The two diverge exactly while the device holds unpushed local edits.

A push request carries the base_version — the server_version the client last saw for that record. The server detects a conflict by comparing base_version against the record's current server_version. If they match, the write applies cleanly and the server assigns the next server_version; if base_version is behind, another device wrote in between and the change enters conflict resolution rather than blindly overwriting.

Server-side optimistic-concurrency check on push
-- Applies only if the client's base_version is still current.
-- Zero rows affected => a conflict; route to conflict resolution.
UPDATE records
SET    payload = :payload,
       server_version = server_version + 1,
       updated_at = now()
WHERE  id = :id
  AND  server_version = :base_version;

A push MUST include the base_version; a write received without one MUST be treated as a conflict, never a blind overwrite.

Definition

Conflict resolution

On a detected conflict, resolution is either whole-record or field-level. Whole-record picks one winner by a fixed total order — server_version, then server-authoritative timestamp, then a stable tiebreak (device id) — so every peer reaches the same result independent of sync order. It is simple and lossy: the loser's fields are discarded. Use it where a record's fields are not independently meaningful.

Field-level merge resolves each field independently and preserves concurrent edits to different fields, but it is safe only for field types with a well-defined merge. Commutativity is a property of the field type, not a blanket guarantee: text and counters merge order-independently; an arbitrary scalar does not. For a field without a safe automatic merge — a status enum, a price, anything governed by business rules — a custom resolver MUST be defined, otherwise the field falls back to whole-record last-writer-wins.

A custom resolver is a deterministic pure function `(base, local, remote) => resolved`, registered per field or record type and run identically on the server and every client so all peers converge. A resolver that is non-deterministic, or differs across peers, breaks convergence and MUST NOT be used.

Field typeMerge strategy
Collaborative textCRDT / operational-transform merge; concurrent edits combine
Numeric counterSum of per-device deltas (PN-counter) — commutative
Set / tagsUnion of adds minus tombstoned removes (OR-set) — commutative
Scalar (status, price)No safe auto-merge — custom resolver, else last-writer-wins
Immutable (created_at)First write wins; never overwritten
Field merge by type

Definition

Deletes are tombstones

A delete sets `deleted_at` rather than removing the row, so the deletion can propagate to peers that still have the record. Rows are only physically purged after every device has acknowledged the tombstone past its sync cursor — a hard delete before sync is an un-propagatable change that resurrects on the next pull.

Delete versus concurrent modify resolves delete-wins: a tombstone MUST take precedence over a concurrent field modification, and the modification is rejected rather than resurrecting the row. A genuine undelete is an explicit new write with a higher version, never an automatic side effect of a late edit arriving.

Contract

Sync scope

A client does not sync the whole database — it syncs its scope: the set of records its identity is entitled to and interested in. Scope is determined by user identity and tenant (the authorization floor), and optionally narrowed by a time window or explicit subscriptions (record sets the client has asked for). The server MUST enforce the authorization part regardless of what the client requests — a client MAY narrow its scope but MUST NOT widen it beyond what its identity permits, and the server filters every pull accordingly.

When a record leaves a client's scope — reassigned, archived, or aged past the time window — the server MUST push a scope tombstone so the client drops its now-orphaned copy, rather than leaving stale data on the device. A scope tombstone is distinct from a delete: the record still exists server-side; it is simply no longer in this client's scope.

Scope is enforced server-side; client-supplied filters MAY narrow a pull but MUST NOT be trusted to widen it.

Contract

Sync protocol

PhaseRule
PullFetch in-scope server changes since the stored cursor, one page at a time
ResolveApply the conflict rule locally (whole-record or field-level)
PushSend dirty records in batches with base_version; clear dirty only on per-record ack
AdvanceMove the cursor only after a fully-acked round
Pull–resolve–push–ack cycle

Contract

Failure handling

A rejected push is not a lost change. When the server rejects a push, the client MUST keep the record dirty and retry: a conflict rejection routes the record through conflict resolution; a validation rejection that cannot be auto-resolved MUST surface to the user rather than being silently dropped; a transient error retries with exponential backoff.

The acknowledgement path MUST be idempotent. A client that crashes after the server committed a push but before recording the ack will re-send on restart; the server MUST detect the duplicate — by (record id, version) or a client-supplied idempotency key — and treat the re-send as a no-op returning the same ack, so a crash mid-ack never double-applies or double-counts.

FailureHandling
Push rejected — conflictRoute through conflict resolution; keep dirty; retry
Push rejected — validationAuto-resolve if possible, else surface to the user; never silently drop
Push rejected — transientRetry with exponential backoff
Crash during ackRe-send on restart; server dedupes by (id, version) / idempotency key
Partial batch failureAck per record; retry only the failed records
Failure modes and required handling

A batch push MUST be acknowledged per record, not all-or-nothing, so one bad record does not force the whole batch to re-send.

Contract

Scalability

Pull operations MUST be paginated. A pull returns at most a page of changes (default 500 records) plus a cursor; the client repeats until the cursor is drained, so a large initial sync streams in bounded pages rather than one unbounded response. The cursor is the server high-water mark from the checkpoint and is opaque to the client.

Push operations SHOULD be batched (default 100 records per request) to amortise round-trips, subject to the per-record acknowledgement rule above. For large datasets, sync deltas rather than snapshots — only records changed since the cursor — and keep large binary blobs in a separate table or object store, synced by reference, so a metadata sync is never blocked behind multi-megabyte payloads.

OperationDefaultMechanism
Pull page size500 recordsCursor-paginated; repeat until drained
Push batch size100 recordsPer-record ack; retry failures only
Large blobsBy referenceSeparate table / object store; sync metadata first
Ongoing syncDelta onlyChanges since cursor, not full snapshots
Scale defaults (tunable per deployment)

Invariants this spec guarantees

  • Record IDs MUST be collision-resistant and client-generated (UUID or equivalent); two devices do not mint the same ID for different records.
  • A push MUST carry its base_version; the server applies it only when base_version equals the current server_version, and otherwise routes it to conflict resolution rather than overwriting.
  • Conflict resolution is deterministic and runs identically on server and clients, so peers converge independent of sync order — whole-record by total order, or field-level only where the field type has a well-defined merge.
  • Delete-versus-modify resolves delete-wins; a tombstone is never overwritten by a concurrent edit, and rows are not hard-deleted before every device has synced past the tombstone.
  • Scope is enforced server-side: a client MAY narrow its scope but MUST NOT widen it, and records leaving scope are pushed as scope tombstones.
  • Acknowledgement is idempotent and per-record — a crash mid-ack re-sends safely and a partial batch retries only failed records — so no acknowledged change is lost or double-applied.

Revision history: revised on 24 August 2026 to add separate client/server versioning with base_version conflict detection, whole-record vs field-level merge with per-type commutativity and custom resolvers, server-enforced sync scope with scope tombstones, idempotent per-record failure handling, and pagination/batching/delta-sync for scale. Conformance language follows RFC 2119.

Want this specified for your system?

We turn definitions like these into the actual schema, policies, and contracts your system runs on. Fixed scope, fixed price, defined delivery date.

Request a Fixed-Scope Architecture Blueprint