docs / offline-sync 2026-08-01
Foundations

Offline-first sync: capable, not unbounded

How the app decides which writes queue offline, and how a queued write reaches the server exactly once

The signal at Folake's pharmacy drops in the afternoon, while a supplier is unloading a new antiseptic at the counter. She fills the Add product form anyway and taps Save. The screen answers Product saved with a Waiting for internet pill, and the write waits on her phone. When the signal returns, the app sends it once, and the antiseptic joins her live inventory exactly as she typed it.

The offline boundary #

Two writes queue offline: creating a product and creating an employee. A write earns that treatment by passing two tests. The server must be able to apply it exactly once, however many times the phone sends it. And the phone must already hold everything the write needs, so the member can complete it from the form alone. The two creates pass both: each is a self-contained request, and each lands on an endpoint that answers a repeated request with the original result.

Every other write fails a test. A stock movement fails the second: the sensible quantity depends on the product's current level, and the phone keeps zero local copy of levels, so movements stay online until the read cache ships. Payroll fails the second more deeply: the numbers a run commits come from the server's authoritative calculation, so the phone alone can produce zero of them. Edits and settings writes fail the first: their endpoints ship without idempotency keys today, and the settings doc carries the last-write-wins rule they follow instead.

Reads sit outside the queue entirely. Screens read the server at launch, and the phone keeps zero local read cache, so a fresh app start offline shows an error state with retry. Home and Alerts soften this for a session in progress: each keeps the last payload it fetched and renders it under an Offline pill, and that payload lives for the session alone. The local read store is a later slice.

Idempotency claims (idempotency_keys) #

A queue is safe only when sending a write twice changes the record once. The server's half of that guarantee is the idempotency claim, a record that stops a retried write from applying twice. An offline-capable create sends an Idempotency-Key header, a UUID the app fixes at the moment the write is queued.

A claim is scoped to the business, the operation type, and the key, and it commits in the same transaction as the write it protects, so the write and its claim land together or roll back together. A replay with the same payload returns the stored outcome, whichever it was: the claim stores the response, so a 201 created and a 202 pending approval both replay exactly. A replay with a different payload answers 409 idempotency_conflict, and a second request while the first is still finalizing answers 409 idempotency_in_progress.

Five create commands carry keys on the wire today: product, stock movement, employee, compensation profile, and payroll draft. The offline queue rides two of them; the other three protect an online save whose response was lost, so a resubmitted form lands once. The server ships zero sync endpoints: a queued write replays the same POST /products or POST /employees the online path sends, and the claim makes the replay safe. The inventory doc carries the product-create wire detail, and the approvals doc carries the 202 envelope.

Claims on the wire
  • The claim key is the triple of business_id, operation type, and key, so one UUID can recur across businesses or operations without collision.
  • A missing or blank Idempotency-Key answers 400 idempotency_key_required.
  • The claim stores enough of the outcome to replay the response safely, and it is an append-only record in the trust group of the entity model.
  • Payroll finalization uses a different guard: it sends If-Match with the reviewed run version, and a duplicate finalize is refused by the version check.

The outbox (sync_outbox) #

The phone's half is the outbox, one SQLite table that holds every queued write as a durable row. The save writes the row inside a transaction, so a queued write survives an app kill or a restart and replays when the app next runs.

The row id doubles as the idempotency key. The key is fixed when the member taps Save, before the network is involved, so a create whose response is lost mid-flight replays under the same key and lands once.

The row stores the request exactly as the app will send it: the method, the endpoint, and the JSON payload byte for byte, plus a fingerprint of the payload (request_hash) that guards the stored body against accidental change. Payload, key, and fingerprint stay frozen once queued. The row also captures three presentation fields at enqueue time, a category, an entity label, and a one-line summary, so the pending screens render from local rows alone.

A row carries one of six statuses through its life: queued, syncing, synced, pending_approval, failed, and blocked.

The outbox record
FieldPurpose
idLocal operation id and the idempotency key
business_idBusiness scope for authorization and cache isolation
operation_typeStable command name, such as product.create
method, endpointThe HTTP request the replay sends
idempotency_keyHeader value reused for every replay
request_hashPayload fingerprint that detects accidental mutation
payloadJSON request body exactly as it will be sent
statusOne of the six durable statuses
approval_request_idJoin handle from a 202 pending-approval response
attempt_count, next_attempt_at, last_attempt_atRetry accounting and schedule
last_error_code, last_error_messageThe latest failure, user-readable
category, entity_label, summaryEnqueue-time presentation for the Sync tab

Replay #

An offline-capable save queues first and sends at once. With the network and the server healthy, the row settles in the same interaction: a success marks it synced, and a 202 pending-approval body marks it pending_approval while the form confirms Sent for approval. Offline, or on a 5xx, the row stays queued for the worker, and the screen shows the saved confirmation with its Waiting for internet pill. A validation error at save time rolls the queued row back and returns the member to the form with the error, so the queue holds writes the network alone is blocking.

From there a background worker owns the queue. The worker is single-flight, so overlapping triggers collapse into one pass, and it re-reads the session before every operation, so signing out halts it mid-batch. Per row it marks syncing, counts the attempt, replays the stored payload under the original key, and writes the outcome back as a durable status.

The worker wakes on four signals: a sweep when the app starts, regained connectivity, the app returning to the foreground, and a request from a save whose own attempt just failed as retryable. The fourth exists because the first three can all miss: when the server is down while the phone's network stays steady and the app stays foregrounded, zero external signals fire. After each pass the worker also arms a timer for the earliest scheduled retry among queued rows, which covers the quietest case of all: the server alone coming back. Retries back off deterministically, 2 seconds after the first failed attempt, doubling per attempt, capped at 5 minutes, and only a queued row whose scheduled moment has passed is drained.

Every attempt settles the row into a durable place. A success is synced, and the error fields clear. A transient failure stays queued with a schedule: the network was away, the server answered a 5xx or a rate limit, or the same key was still finalizing. A payload the server refuses becomes failed, visible and fixable by the member. A 403 or a conflicting replay becomes blocked: only a person can move it, by a capability decision or by resolving the conflict. A 401 pauses the whole loop with zero schedule; the rows stay queued and replay once the session refreshes.

pending_approval is the remaining resting place. The server accepted the write and a decision is pending, so the worker leaves the row alone: replaying the key would return the same stored envelope, and only the owner's decision moves the request.

Ngozi's Paracetamol 500mg submission traveled this machinery. Her save queued the write durably and the inline replay carried it in the same interaction: the server answered the 202 envelope, the row settled pending_approval, and the envelope's request id was stored beside it as the join handle for the decision. The screen confirmed Sent for approval, her Sync tab gained the card "1 product is waiting for approval", and the bell badge stayed dark, because the row asked nothing further of her. Had the stockroom been a dead spot, the same row would have rested queued first and the worker would have carried it on reconnect, to the identical resting place.

The disposition table
Attempt outcomeNext statusAuto-retry
Successsynced, error fields clearedsettled
Success with the pending-approval bodypending_approvalsettled; the owner's decision resolves it
Zero response, network awayqueued, scheduledyes
5xxqueued, scheduledyes
429 rate limitedqueued, scheduledyes
409 idempotency_in_progressqueued, scheduledyes; the first request is still finalizing
invalid_response, a success status with a non-JSON bodyqueued, scheduledyes; a captive portal or wrong server answered, so the key stays safe
401queued, zero schedulepaused until the session refreshes
422, 400 key errors, 404, other 4xxfailedmanual; the payload needs correction
403blockedmanual; a capability decision is needed
409 idempotency_conflictblockedmanual; a different payload reused the key

Backoff is deterministic with zero jitter, so the schedule is testable: 2s, 4s, 8s, capped at 300s.

The Sync tab #

The Alerts screen hosts a Sync filter tab, and it reads the phone's outbox alone: the cards render from SQLite, offline, with zero server involvement.

Waiting changes group into one card per category, each with a count, the moment of the latest change, and a View all link into a pending-changes screen. Detail rows show the entity label, the summary, and the moment, newest first, and a row disappears the instant the worker drains it: the list reloads on focus and on every replay transition. A waiting-for-approval row renders as its own card beside the waiting-to-sync cards, and the tab's count covers both populations.

The amber banner "Connect to the internet to sync these changes" appears only while the phone reports offline; online, the copy says the changes sync automatically. With an empty queue the tab reads "You're all synced".

The tab-bar bell badge adds the local pending count to the server's open-alert count, and the local half comes from SQLite, so a queued offline write lights the badge with zero server round trip. The badge counts actionable rows alone: queued, syncing, failed, and blocked. A pending_approval row stays off the badge and on the tab, so the badge lights exactly when something needs a person's attention.

Launch boundaries #

The model is built so later workflows arrive as additions. The boundaries today:

  • The offline queue covers product creation and employee creation alone; every other write needs a connection.
  • Screens read the server; the local read store is a later slice, and stock movements queue offline only after it lands.
  • A failed or blocked row stays visible on the Sync tab; the surface that retries or re-queues it by hand is a later milestone.
  • A pending_approval row keeps its request id, and decision reconciliation settles it in a later milestone; the approvals doc carries the requester-side read that will drive it.
  • Signing out resets the local database, and queued unsynced writes go with it; the settings doc carries the dialog that says so.