Ask a retailer what they need from a point of sale and "works when the internet is out" comes up fast. It sounds like a feature. It is an architecture, and it is the one that determines most of the rest of the system.
A register that requires the network is a thin client. A register that does not is a replica — a node holding its own copy of the catalog, its own transaction log, and its own opinion about the state of the world, which will need reconciling with everyone else's later.
What actually has to work offline
Not everything does, and pretending otherwise is how these projects get expensive. The honest list is short:
- Ring up a sale from the local catalog, including price lookups and tax
- Take payment — cash always, card if the terminal has its own connectivity
- Print a receipt and open the drawer
- Record the transaction durably enough to survive the machine being unplugged
What does not need to work offline: real-time stock across other locations, loyalty balance updates, marketplace sync, reporting. Those degrade to a stale read or a queued write, and the cashier never notices.
Sale as an append-only fact
The design that makes this tractable is treating a completed sale as an immutable event rather than a mutation of shared state. The register does not decrement a central stock count; it records that a sale happened, locally, with a client-generated id. Stock levels are derived from the event stream when it lands.
This buys idempotency almost for free. A register that reconnects and is unsure which of its queued sales made it through can safely resend all of them, because the id makes duplicates detectable at the server. Without that, every flaky reconnection is a potential double-charge, and reconnections are always flaky.
Design so that the ugly case — a register replaying its queue after two hours offline — is the same code path as the happy one.
The conflicts you cannot avoid
Two registers sell the last unit of something while both are offline. There is no clever data structure that prevents this; the information needed to prevent it did not exist in either place at the time. Stock goes to negative one, and the system's job is to be honest about it rather than to pretend.
So negative stock is a valid state that raises an exception for a human, not an error that blocks the sale. The alternative — refusing the second sale — means refusing a customer holding cash because of a synchronisation detail. No retailer will accept that trade, and they are right not to.
Where the browser stops being enough
Offline logic lives happily in a browser. Hardware does not. Cash drawers, receipt printers and scanners speak protocols the web platform reaches only partially, and the gaps are frustrating: a page served over HTTPS cannot open a plain-HTTP connection to a printer on the local network without tripping mixed-content rules, so the relay has to run on localhost or the connection has to go through WebUSB.
That constraint — not app-store presence, not push notifications — is the honest reason to ship a native wrapper. The web app is the product; the native shell exists because a cash drawer needs to open.
The test that matters
Unplug the network mid-transaction. Then unplug the machine. Then plug both back in and confirm the sale is exactly where it should be, once. Any offline story that has not survived that sequence on real hardware is a hypothesis.
