A restaurant POS is one of the few consumer-scale products where offline operation is not a nice-to-have. The venue's network is consumer broadband shared with a card terminal and a guest wi-fi network, and it will fail during service. When it does, the software has to keep taking orders and firing tickets or the business physically stops.
This post covers the concrete architecture: what lives locally, how identifiers work, how divergent terminals merge, and which operations genuinely cannot be merged.
Local-first writes
The terminal holds a local database containing the current menu, open checks, the session's orders, and an outbound operation queue.
Every user action writes locally and synchronously. Nothing in the interaction path waits on a network call — an action that blocks on a round trip is a defect that will be discovered at peak service, when a queue of actual people is forming.
Synchronisation is a background process reading the outbound queue and reconciling with the server. It has no bearing on whether the user can proceed.
Client-generated identifiers
All identifiers are generated on the terminal. Universally unique values, never server-assigned sequences.
This matters more than it first appears. A server-assigned identifier means an order created offline has no identity until it syncs, so every local reference is provisional and must be rewritten afterwards. Every relationship becomes a two-phase problem, and the bugs that result are subtle and hard to reproduce.
Client generation makes an offline-created order a first-class entity from the moment it exists. Human-facing order numbers can still be assigned per-venue per-day locally, with a terminal prefix to avoid collisions.
Checks as operation streams, not documents
Here is the core modelling decision.
Do not represent a check as a mutable document that terminals overwrite. Represent it as an append-only sequence of operations:
{ id, checkId, terminalId, seq, ts, type: 'ADD_ITEM', payload: { itemId, qty, seat, modifiers } }
{ id, checkId, terminalId, seq, ts, type: 'VOID_ITEM', payload: { lineId, reason } }
{ id, checkId, terminalId, seq, ts, type: 'APPLY_DISCOUNT', payload: { ... } }
The current state of a check is a fold over its operations. Two terminals that diverged offline are merged by unioning their streams and replaying, which is commutative for most operation types.
Compare this with the naive approach. Last-write-wins on a whole check produces a specific, expensive bug: the bar terminal added a drink, the till voided a starter, and whichever version arrives second silently discards the other terminal's work. One direction is uncharged revenue; the other is a customer billed for something they sent back. Both destroy operator trust faster than nearly any other defect.
Operations that cannot be merged
A small set genuinely requires arbitration, and it is better to be explicit than to pretend otherwise:
- Settling a check. Two terminals settling concurrently must not both succeed. This requires a server round trip, or a venue-local coordinator when the cloud is unreachable.
- Voiding an item already served. A void that arrives after the kitchen has fired and served the item is a business decision, not a data merge.
- Anything with an external side effect — refund issued, loyalty points redeemed — where reversing costs real money.
Define this list explicitly, surface the requirement in the interface, and design the fallback deliberately. Telling a server that payment needs connectivity is much better than allowing two payments to land and reconciling through support tickets a week later.
Visible failure beats silent failure in this environment. Staff need to know the terminal is offline and orders are queued; ambiguity produces double-fired tickets.
The local real-time layer
Kitchen displays are a local problem, not a cloud one. A fired course needs to appear on the correct station within about a second.
Terminals and kitchen screens on the same venue network should communicate directly, with the cloud as a synchronisation channel rather than as the message bus. Routing a ticket from a till to a screen four metres away via a remote data centre is one dropped connection away from a service failure, and that is a design choice rather than bad luck.
Also budget for printers. Every venue has thermal printers, and escape codes, network discovery, per-model quirks and paper-out handling form a deeper body of work than anyone plans for.
The data model decision that matters later
Store operations, not summaries. Item-level, timestamped, with modifiers and voids preserved rather than collapsed into settled totals.
The reason is that the highest-value features arrive later and all depend on this history: demand forecasting at item level to drive prep and ordering, labour scheduling derived from those forecasts, and anomaly detection on voids and discounts. A team that stores only check totals has thrown away the input those features require, and it cannot be reconstructed.
This is the single cheapest decision to make correctly in week three and the most expensive to regret in year two. More on the applied-model side in our work on AI development services.
Full build guide covering the domain model, modifier design, payments, tenancy, hardware and costs: Cloud Based Restaurant POS Systems: The 2026 Build Guide.
Frequently Asked Questions
Is this just event sourcing?
It shares the mechanism but applies it locally for a specific reason: enabling deterministic merges between devices that diverged without coordination. You do not need a full event-sourced backend to benefit — the operation stream can be a local concern that projects into conventional server-side storage.
What storage should the terminal use?
Anything durable with transactional writes and reasonable query support — SQLite on native platforms, IndexedDB in the browser. What matters far more than the choice is that writes are synchronous from the user's perspective and never block on the network.
How do you handle clock skew between terminals?
Do not rely on wall-clock time for ordering. Use per-terminal sequence numbers plus a logical clock, keeping timestamps for display and audit only. Terminal clocks drift, and merges that depend on them produce non-deterministic results.
How large can an operation stream get before it needs compaction?
A single check rarely exceeds a few dozen operations, so per-check folding is trivial. Compaction matters at the venue-day level for archival — snapshot the settled state and retain the operation history separately, keeping it rather than discarding it, since it is the input to later analytics.
What happens if a terminal is offline for days?
Bound it. Define a maximum divergence window, typically a single service day, after which the terminal requires reconciliation before accepting new work. Unbounded divergence produces merges nobody can reason about and staff cannot explain to a customer.


Top comments (0)