All guides
Operations

How an offline-first retail POS keeps data consistent when the network comes back

Published on 10 min read

In a shop in Douala or Yaoundé, the power flickers, the mobile data drops for twenty minutes, and a customer is still standing at the till with cash in hand. A point-of-sale that stops working the moment the network does is not a rounding error — it is lost revenue in CFA francs, several times a day. CEMAC POS is built for that reality: it is offline-first, not offline-tolerant. This is the actual sync architecture underneath it, and the deliberate engineering choices behind it.

Local-first: the till owns its own database

The foundational decision is that each device holds its own database, and the checkout loop never depends on the network to complete a sale. Scanning items, applying the VAT-inclusive price, taking payment, printing or displaying a receipt — all of it reads and writes locally, at local latency. The network is treated as an eventual delivery channel, not a dependency. A cashier in a market stall with one bar of signal has exactly the same checkout speed as one on fibre.

This inverts the assumption behind most cloud-dependent POS systems, where every sale is a round-trip to a server. On the intermittent grids and networks common across Cameroon and the wider CEMAC region, that round-trip is the single point of failure. Removing it from the critical path is the whole game.

Sales: an append-only outbox

When a sale is completed offline, it is written to an append-only outbox on the device. Append-only matters: a sale is an immutable fact once it happens. The outbox is never edited or reordered to "fix" a record — it is a durable log of things that occurred, waiting for a channel to the server. Each sale is minted locally with its own UUID at the moment of sale, on the device, before any sync is ever attempted.

That client-minted UUID is the linchpin of the whole design. Because the identity of a sale is decided at the edge, the server never has to invent one, and the same sale carries the same identity no matter how many times it is later transmitted.

Reconnect: idempotent upload on the client-minted UUID

When connectivity returns, a background pipeline drains the outbox and POSTs the queued sales to /sync/push. This endpoint is idempotent on the client-minted UUID. Before inserting, the server checks whether it already holds that id — in effect, Sale::whereKey($id)->exists() — and if it does, the upload is acknowledged and ignored rather than reinserted.

This is what makes flaky networks safe. A request can time out after the server committed but before the device saw the acknowledgement; the device retries; the sale arrives a second time. With idempotency keyed on identity, that duplicate is processed exactly once. No double-counted revenue, no phantom sale in the ledger. The retry is not just tolerated — it is the expected, correct behaviour of the queue.

The plain sale-creation path is not idempotent — only /sync/push dedupes on the client-minted UUID. Any code that can retry (an offline queue, a flaky connection) must go through /sync/push, never a direct insert. This distinction is the difference between "processed once" and "charged twice".

[ Local device ]
  append-only outbox  (sale minted with client UUID)
        |
        |  (reconnect — background pipeline)
        v
  POST /sync/push  ---> dedupe: Sale::whereKey(uuid)->exists()?
        |                     |                  |
        |                   (no) insert       (yes) ack + ignore
        v                     |                  |
  [ Server ledger : immutable, hash-chained ] <--+

  [ Catalogue / products / customers ]
  device  <---- last-write-wins ----  [ Server ]
           (owner is the single writer)

Catalogue: last-write-wins, on purpose

Products, prices and customers sync in the other direction using last-write-wins, and this is a deliberate choice rather than a compromise. The catalogue has, in practice, a single writer: the shop owner or manager decides what a product costs and what is on the shelf. When effectively one authority edits a record, the most recent write is the correct write. Layering a merge algorithm on top of a single-writer dataset adds machinery and failure modes for a conflict that does not occur in the field.

So the till pulls the catalogue and the last write wins. It is simple, it is robust, and it matches how a real shop is actually run.

Why not CRDTs?

The reflexive answer to "sync" in 2026 is conflict-free replicated data types. For a retail POS they are the wrong tool, and understanding why is the point of this whole architecture. CRDTs earn their complexity when multiple peers concurrently mutate the same shared state and those edits must merge without a coordinator — collaborative documents, multiplayer canvases. A POS has neither of the two problems CRDTs solve.

  • Sales are append-only, so they never merge-conflict. Two tills never edit the same sale; each mints a distinct fact with its own UUID. Idempotent upload keyed on that UUID is sufficient and far cheaper than a mergeable data structure.
  • The catalogue has a single writer, so there is no concurrent-edit conflict to resolve. Last-write-wins is the correct semantics, not a lossy fallback.
  • CRDT metadata grows with history and complicates auditing — the opposite of what an OHADA-compliant, hash-chained, immutable ledger wants.

CEMAC POS therefore uses no third-party sync engine — no CRDT library, no external replication service. The sync layer is deliberately small, auditable code we own end to end, because on shared infrastructure with no shell access, a dependency you cannot inspect is a liability you cannot fix.

The API is never cached

One rule sits above the rest: the API is never cached. Reads for prices, stock and totals always reflect authoritative state. The principle is blunt — stale money is worse than no money. It is safer for the till to work entirely from its own local database than to serve a plausible-but-wrong figure from a stale cache. Offline correctness comes from the local store, not from caching the network.

Refused sales are surfaced, never dropped

If the server refuses a queued sale — a validation failure, a tenancy problem, a fiscal-adapter rejection — it is not silently discarded. It is surfaced for review. The append-only outbox means the record still exists on the device; the failure is visible to a human, who can correct and resubmit. In a system handling real money, the unacceptable outcome is a sale that quietly vanishes between the till and the ledger. That cannot happen here.

Property Cloud-dependent POS Offline-first (CEMAC POS)
Checkout during outage Blocked — till unusable Full speed — runs on local DB
Duplicate-sale risk on retry High — non-idempotent re-POST None — idempotent on client UUID
Data cost Every sale is a round-trip Batched background sync only
Complexity Server on the critical path Small owned sync layer, no CRDT engine

The shape of it

Put together, the architecture is small on purpose: a local database the checkout loop never leaves, an append-only outbox of client-minted sales, an idempotent /sync/push that dedupes on identity, a last-write-wins catalogue pull for the single-writer data, an uncached API, and refused records surfaced for a human. No CRDTs, no external sync engine, no magic — just the minimum set of guarantees a POS actually needs on an unreliable grid, and nothing that would make it harder to audit the money.

Start selling offline-first with CEMAC POS

Frequently asked questions

How are duplicate sales prevented when the network comes back?

Each sale is minted on the device with its own UUID and queued in an append-only outbox. On reconnect it is POSTed to /sync/push, which is idempotent on that client-minted UUID: if the server already holds the id it acknowledges and ignores the upload instead of reinserting. A retried or duplicated upload is therefore processed exactly once.

Do you use CRDTs?

No. A POS has neither problem CRDTs solve. Sales are append-only and never merge-conflict, so an idempotent upload keyed on a client UUID is enough. The catalogue has a single writer (the owner), so last-write-wins is the correct semantics rather than a lossy fallback. CRDTs would add metadata, complexity and audit noise for conflicts that do not occur.

What happens to a sale the server refuses?

It is surfaced for review, never silently dropped. The append-only outbox keeps the record on the device, so a validation, tenancy or fiscal rejection becomes visible to a human who can correct and resubmit it. A sale never vanishes between the till and the ledger.

Does the till ever block on the network?

No. The checkout loop reads and writes the device's own local database and completes sales without any network call. A background pipeline handles sync separately, and the API is never cached because stale money is worse than no money.

Try CEMAC POS

The till built for shops in Cameroon: offline, Mobile Money, loyalty and OHADA compliance. 30 days free, no card required.

Start the free trial

Comments (0)

No comments yet. Be the first to share your experience.

Leave a comment

Read next