Skip to content

← All insights

Insight

Offline-first point of sale: keeping the till selling when the network drops

How a restaurant POS we built writes every sale locally first, queues it for the server, and makes retries harmless when the connection comes and goes.

Bolt Fusion Tech engineering

A till that waits for the network loses sales

A point of sale is the one screen in a restaurant that cannot stop. When the Wi-Fi drops during a busy service, the cashier still has a queue of people in front of them. If ringing up an order means waiting on a server, every network hiccup becomes a line at the counter.

So in a restaurant POS we built, the network is never in the path of a sale. The till writes the order to a local database, the cashier moves on, and a background worker delivers the order to the server whenever it can.

This article walks through the pieces that make that safe: the outbox, the sync worker, the server’s idempotent batch endpoint, how conflicts are kept from arising, and receipt printing that never waits on the internet.

The local database is the source of truth

The desktop till is an Electron app with SQLite in the main process, in write-ahead-log mode. The phone till uses WatermelonDB, which is SQLite underneath. Both read and write locally first, so the till behaves the same offline as online, and the menu lives on the device.

Order ids are generated on the device, not by the server: a prefix, a base-36 timestamp, a per-process counter and a random suffix, so two orders created in the same millisecond still differ. Because the device owns the id, an order exists the moment it is rung up, and the same id is the key the server uses later.

Write the order and its outbox row in one transaction

Every change that has to reach the server is recorded in an outbox table, in the same transaction as the order itself. If the order is written, its outbox row is written; if either fails, neither exists. There is no window in which a sale is saved but forgotten by sync.

Each outbox row carries a frozen JSON snapshot of the order as it was at write time, an attempt counter, the last error, and the earliest time it may be tried again. A row is pending while its synced timestamp is empty. Acknowledged rows are kept for a month and then pruned, so we can still answer the question support always asks: did this order ever reach the server?

SQL

CREATE TABLE IF NOT EXISTS sync_outbox (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  entity TEXT NOT NULL,           -- 'order'
  entity_id TEXT NOT NULL,        -- the server's idempotency key
  operation TEXT NOT NULL,        -- 'create' | 'update'
  payload TEXT NOT NULL,          -- JSON snapshot, frozen at write time
  attempts INTEGER NOT NULL DEFAULT 0,
  last_error TEXT,
  created_at TEXT NOT NULL,
  next_attempt_at TEXT NOT NULL,  -- backoff schedules the next try
  synced_at TEXT                  -- null while pending
);

CREATE INDEX IF NOT EXISTS idx_sync_outbox_pending
  ON sync_outbox(next_attempt_at) WHERE synced_at IS NULL;
The desktop till’s outbox, from its SQLite schema, comments trimmed. The partial index keeps the query that claims the next batch cheap however much synced history builds up.

Draining the queue

A sync worker drains the outbox in batches, oldest first. On the desktop it runs in Electron’s main process, so the cashier’s screen never takes part in the HTTP round trip; the window only receives status updates.

It wakes on five triggers. Before sending, the phone till asks whether the internet is actually reachable, and asks twice, because the connectivity API can report an unknown state right after launch.

  • A debounced kick after every local write, so a burst of orders becomes one batch.
  • A heartbeat once a minute.
  • The device coming back online.
  • The app returning to the foreground.
  • A drain shortly after a cold start.

When a request fails, the worker backs off exponentially: it doubles from one second, caps at five minutes, and adds up to thirty percent of random jitter, so a room full of tills does not retry in lockstep when the connection returns.

  1. Till

    The order and its outbox row, written in one local transaction.

  2. Outbox

    Pending rows, oldest first, each with its next attempt time.

  3. Sync worker

    Debounced, on a heartbeat, backing off with jitter when a request fails.

  4. Batch endpoint

    Each item in its own transaction, upserted by terminal and device id.

  5. Answer

    • Accepted

      Marked synced; kept for a month, then pruned.

    • Refused

      Bad data: parked for an hour, not retried at once.

One sale, from the till to the server and back.

Making retries harmless

Retries are only safe if the server treats a repeat as a repeat. The batch endpoint upserts every order by the pair of terminal and device-generated id, and that pair is a unique constraint in Postgres. A test posts the same batch three times and asserts that exactly one order exists.

Each item in a batch is applied in its own database transaction, so one bad row cannot roll back the rest. The response lists the ids the server accepted and the ids it refused, each refusal with a reason. The till handles the two outcomes differently: a refused row is parked for an hour instead of hammering the server, and a failed request, whether no network, a timeout or an error for the whole batch, goes back on the backoff schedule.

The server keeps the till’s own timestamps. An order rung up at lunch and synced after an afternoon offline still records lunch as the time of sale; the time it reached the server is stored separately. Every applied item is also written to an append-only sync log, which is how an operator answers whether a particular order arrived.

Python

order, created = Order.objects.update_or_create(
    terminal=terminal,
    entity_id=item["entity_id"],
    defaults={...},  # the snapshot's fields, and the raw payload as JSONB
)
The heart of the batch endpoint (Django): an upsert keyed on the terminal and the id the till generated.

Conflicts: design them out before you resolve them

The cheapest conflict is the one that cannot happen. Each order belongs to the terminal that created it: the server’s key includes the terminal, so two tills never write the same order row. Within one till, changes to an order leave the outbox in the order they were made and are applied oldest first.

Every outbox row carries a full snapshot of the order, not a diff. The server replaces the order’s lines and payments with what the snapshot says, so a late or repeated update cannot leave half an edit behind. The rule is last write wins, per order, from the only device allowed to write it.

That design does not solve every conflict, and it does not pretend to. Sync as written carries orders only. The outbox has an entity field so other record types can follow, and the server refuses any entity it does not know. State that several tills change at once, such as stock counts, needs rules of its own, and we would design those per record type rather than bolt a generic merge onto the queue.

Printing a receipt without the internet

Receipt printing is local too. On the phone till in our restaurant POS work, the app builds the receipt’s content once in JavaScript and hands it to a native module, which turns it into ESC/POS bytes: initialise the printer, bold on and off, line feeds, and a paper cut at the end.

On Android the module talks to a paired thermal printer over Bluetooth serial; on iOS it opens a TCP connection to a printer on the local network. Neither path touches the internet, so a customer gets a receipt whether or not the sync worker has reached the server.

What is still open

Three parts of this design are deliberately unfinished or deliberately blunt, and each is written down rather than discovered later.

  • Background sync on iOS. On Android, a scheduled background job drains the queue even when the app has been closed. On iOS we have not built that yet, so a closed app syncs when it is next opened.
  • Record types beyond orders, starting with the ones several tills share.
  • A locked terminal loses its queue. An administrator can lock a terminal; the till treats the server’s refusal as a kill signal, wipes its local database and relaunches into a locked screen. Anything still queued goes with it, which is why a lock is an administrator’s decision and a network error is never read as one.

Have a complex problem?

Let's turn it into an engineered system.

Schedule a CallTell Us About Your Project