Skip to content
All insights

A POS that keeps selling offline

How an offline-first POS uses local storage, a durable event queue, and idempotent sync to keep recording sales during an outage.

Architecture. 23 October 2026. 6 min read.

For a multi-branch retailer, a network outage can stop a counter or push staff toward a temporary spreadsheet. Counterbase is an Arche Axon point-of-sale system designed for offline operation. Its mobile app keeps sales locally and syncs them when connectivity returns. The reliability question is not exactly-once delivery, which a network cannot promise, but whether retries can create a second server-side sale. This article follows the data path and the checks that prevent that duplicate effect.

The local database is the source of truth while you are offline

Counterbase is a Flutter application that carries its own SQLite database (via the Drift library) on every device. When a cashier completes a sale, the app writes the full sale, its line items, and the receipt number to a pending sales table on the phone. The id for that sale is a UUID generated on the device, not a number the server hands out, because there is no server to ask. The sale is immediately visible in the day's takings, the stock levels for that branch drop, and the receipt prints. Nothing in the checkout path waits for a network response.

This is the decision that defines offline-first: the local database is authoritative for the shift. The server is authoritative for history. A cashier never sees a spinner where a receipt should be.

Every offline action becomes an event in a queue

Alongside the sale itself, the app writes one row to a sync queue table. The row says what happened, to which record, in what order. Sales, voids, refunds, stock adjustments, supplier invoices, supplier payments, and customer edits all travel through this one queue.

The sync queue table, simplified from Counterbase's local schema
class SyncQueue extends Table {
  TextColumn get id => text();              // UUID for this event
  TextColumn get entityTable => text();     // sales, customers, inventory...
  TextColumn get recordId => text();        // which record, e.g. the sale UUID
  TextColumn get action => text();          // create, update, delete
  TextColumn get eventType => text();       // SALE_CREATED, STOCK_ADJUSTED...
  TextColumn get payload => text();         // the full data as JSON
  TextColumn get deviceId => text();        // which phone/tablet raised it
  IntColumn  get sequenceNumber => integer(); // local order, never reused
  TextColumn get status => text();          // pending, synced, conflict...
  IntColumn  get retryCount => integer();
  IntColumn  get maxRetries => integer();
  DateTimeColumn get nextRetryAt => dateTime().nullable();
}

The sequence number orders items in one device's local queue; it is not a global clock across branches. The event UUID identifies a queued event for server-side deduplication. The payload records the data to transmit. If the underlying record can change before sync, define whether the event is an immutable snapshot or a reference to the latest value, and make that contract explicit.

Push, pull, and the two-minute heartbeat

Sync runs on three triggers. When connectivity returns, the queue drains immediately. A periodic push runs on a timer while the device is online. And a pull runs every two minutes, bringing down changes from other devices in the same branch: sales made at counter two, stock received in the storeroom, price edits. Without the pull, a sale made on device A would only appear on device B when device B happened to make its own sale.

The pull uses an overlap window of about ten minutes: it asks the server for everything changed since the last cursor, minus ten minutes. Clocks on phones drift, and a strict cursor silently skips records when a device's clock is ahead. The overlap costs a few repeated rows, which the server deduplicates, and buys safety against skew.

There is also a recovery sweep. If the app dies between writing the sale and enqueuing its event, the sale row would sit unsynced forever with nothing driving it. So the start of every sync cycle re-enqueues any unsynced sale that has no live queue row. Failures are ordinary work, not exceptions.

The same sale must never exist twice

The classic offline-sync bug is duplication: the app sends a sale, the network times out after the server saved it, the app retries, and the customer is charged twice. The queue is designed so the retry path is safe. The server keeps a record of every event it has processed, keyed by device and event id, and checks it before applying anything.

Idempotent event processing, simplified from the Counterbase API
// POST /api/v1/sync/push, simplified
const existing = await db.syncEvent.findUnique({
  where: { deviceId_eventId: { deviceId, eventId: event.eventId } },
});
if (existing) {
  // Already applied. Replay the original result, do not apply again.
  return { eventId: event.eventId, success: true, serverId: existing.entityId };
}
const result = await processEvent(event);   // creates the sale server-side
await db.syncEvent.create({ data: { deviceId, eventId: event.eventId, ... } });

The offline sale's UUID also travels to the server as an offlineId, and the server-side sale creation uses it, so even a queue row rebuilt by the recovery sweep cannot produce a second sale. If a phone sends the same customer twice because it never got the first response, the server recognises the id and treats it as done instead of failing forever.

Conflict rules a supervisor can understand

Two devices can change the same record while disconnected. A sync design should handle that deliberately instead of letting arrival order decide which edit survives. Four cases need separate rules.

  • Concurrent update: compare the server and local versions. Merge only fields known to be independent; send conflicting financial or inventory changes for review or apply a documented precedence rule.
  • Deleted on server: retain a deletion marker long enough for offline devices to learn about it, and do not resurrect the record without an explicit decision.
  • Newer version exists: compare the versions and decide whether to reject, merge, or request human review instead of blindly overwriting the newer record.
  • Duplicate create: use a stable event or business key to identify a replay, then return or map to the existing record when it is the same operation.

Mark unresolved events as conflicts and surface them to someone who can make the business decision. Possible resolutions include keeping the server copy, keeping the local copy, or merging, but only when each option respects the record's business rules. Nothing should be discarded silently, and the operator needs a clear way to confirm that the queue is resolved.

What to check before you buy a claim like this

Offline-first is easy to say in a sales meeting and hard in the code. If a vendor claims it, ask four questions. Where does the sale live first, and can you see the receipt seconds after the network dies? What happens when the app is killed mid-sync, and does anything ever strand? Can the same sale reach the server twice through any retry path? When two devices disagree, who wins and who is told? If the answers are vague, the claims are marketing.

Done properly, the result is boring in the best way: the counter keeps moving through the outage, and the queue quietly drains when the line returns.

Related articles

Talk through the problem.

Tell us about the workflow, integration, or data move you are planning.

Start a project

Retail, POS & commerce