Building a POS system that keeps selling when the internet drops
Offline is not a POS feature, it's the constraint that decides your data model. Invoice numbering under partition, append-only stock ledgers, idempotent sync, and why overselling is a business event rather than a bug.
A point of sale system has one requirement that separates it from almost every other web application I've built: it has to complete a sale when the network is gone.
A customer standing at a counter with cash in their hand will not wait for your API to come back. The shop will not close because an ISP had a bad afternoon. If your POS shows a spinner during a power flicker, the staff will stop using it and go back to a receipt book — and then you have two sets of books and no idea which one is real.
So "offline support" is not a feature you add in v2. It is the constraint that decides your entire data model. Here is how I've learned to build for it across the retail and hospitality POS systems I've worked on.
The terminal owns its own sales
The instinct from web development is that the server is the source of truth and the client is a view. For a POS, that is backwards.
The correct model is: each terminal is authoritative for the sales it rings up. The server is authoritative for everything shared — the product catalogue, prices, users, and the consolidated reporting view. Sales flow up. Configuration flows down. They never contend for the same rows.
This split is what makes offline tractable. A sale is a fact that happened at a specific till at a specific time. It is not a request for permission.
class Sale(models.Model):
# Generated on the terminal, before anything touches the network.
# This is the real primary key as far as the business is concerned.
client_uuid = models.UUIDField(unique=True, editable=False)
terminal = models.ForeignKey("Terminal", on_delete=models.PROTECT)
invoice_series = models.CharField(max_length=16)
invoice_number = models.PositiveIntegerField()
# When the sale happened, per the terminal, and when we received it.
occurred_at = models.DateTimeField()
received_at = models.DateTimeField(auto_now_add=True)
total_minor = models.BigIntegerField() # paisa, never a float
currency = models.CharField(max_length=3, default="NPR")
class Meta:
constraints = [
models.UniqueConstraint(
fields=["terminal", "invoice_series", "invoice_number"],
name="unique_invoice_per_series",
)
]
Two things matter here. The client_uuid is generated on the device before the sale is even finalised, which makes every sync operation idempotent — I'll come back to that. And the money is a BigIntegerField of minor units, not a DecimalField and certainly not a float. More on that too.
Invoice numbering is the hard part, and nobody warns you
This is the problem that catches every team building their first POS.
Tax authorities generally require invoice numbers to be sequential and gapless within a fiscal year. In Nepal you also register the billing software itself with the Inland Revenue Department, so the numbering behaviour is not something you can improvise later.
Now hold that next to the offline requirement. A gapless central sequence means asking the server for the next number. Asking the server means being online. You cannot have both.
Three ways out, in increasing order of how much I like them:
Allocate blocks in advance. The server hands each terminal a range — say 5001 to 6000 — and the terminal consumes it locally. Works, but if a terminal dies mid-block you have a hole in the sequence, and explaining a hole to a tax auditor is not a conversation you want.
Queue the number assignment. Print a provisional receipt offline, assign the real invoice number on sync. This is the one that looks cleanest in a design doc and is worst in practice. The customer already walked out with a piece of paper. If the number on their receipt does not match the number in your books, the receipt is worthless for their own accounting.
Give every terminal its own series. T01-000001, T02-000001, and so on. Each terminal keeps its own gapless counter, entirely offline, and the series prefix is part of the invoice identity. Register the series. This is what I reach for now.
It is the boring answer and it works, because it stops pretending that a distributed system has a single global counter. Each till has a sequence it can maintain alone, which is exactly the property offline demands.
Sales are append-only; stock is derived
The second architectural decision that makes everything else easier: never store a mutable stock level as the truth.
The tempting design is a products.quantity_on_hand column that you decrement on every sale. It is fast to query and it will ruin your week. Two terminals sell the last unit while both are offline. Both decrement their local copy. Both sync. Now what?
Instead, treat inventory as the sum of an append-only ledger of movements:
class StockMovement(models.Model):
class Reason(models.TextChoices):
SALE = "sale"
RETURN = "return"
PURCHASE = "purchase"
COUNT_ADJUSTMENT = "count_adjustment"
TRANSFER = "transfer"
WASTAGE = "wastage"
product = models.ForeignKey("Product", on_delete=models.PROTECT)
location = models.ForeignKey("Location", on_delete=models.PROTECT)
quantity = models.DecimalField(max_digits=12, decimal_places=3) # signed
reason = models.CharField(max_length=32, choices=Reason.choices)
# Ties the movement back to whatever caused it, so it is explainable.
sale = models.ForeignKey(
"Sale", null=True, blank=True, on_delete=models.PROTECT,
related_name="stock_movements",
)
occurred_at = models.DateTimeField()
Now the two offline sales do not conflict. They are two movements of -1. They sum to -2. Your stock goes to -1, which is not a bug — it is the system correctly telling you that you sold something you did not have.
That is the real shift in thinking. Overselling is a business event, not a data error. A shop with one unit left and two customers has a physical problem that no locking strategy can prevent, because the constraint was never in your database. Your job is to record what happened accurately and surface it, not to pretend it was impossible.
Keep a materialised view or a periodically-refreshed cache for the "current stock" reads that need to be fast, and rebuild it from the ledger. When the numbers look wrong, you can replay the ledger and find out exactly why — which you can never do with a column that has been decremented ten thousand times.
Sync: an outbox and a cursor
The sync layer is less exciting than people expect, which is a good sign. Two mechanisms cover almost everything.
Going up — an outbox on the terminal. Every local write that needs to reach the server gets appended to an outbox table. A background worker drains it in order, retries with backoff, and deletes only on acknowledgement. Because each entry carries the client_uuid, the server can turn the write into an upsert:
@transaction.atomic
def ingest_sale(payload: dict) -> Sale:
sale, created = Sale.objects.get_or_create(
client_uuid=payload["client_uuid"],
defaults=build_sale_fields(payload),
)
if created:
create_stock_movements_for(sale, payload["lines"])
return sale
A retried batch is harmless. A duplicated batch is harmless. A terminal that has been offline for three days and syncs 900 sales at once is harmless. This single property removes most of the fear from the design, and it costs one UUID column.
Coming down — a monotonic cursor. The terminal asks: give me everything that changed after this watermark. Server-assigned sequence numbers work better than timestamps here, because clock skew on a cheap Windows till is real and a clock that jumps backwards will silently skip records.
Which brings me to a rule I now apply without exception: treat every timestamp from a terminal as a claim, not a fact. Store occurred_at as reported, store received_at from the server, and never compute anything financial from device time alone. When they disagree by an implausible amount, flag the terminal rather than silently trusting it.
Printing is where the abstraction leaks
Everything above is clean data modelling. Then you have to put ink on paper, and thermal printers are a different world: ESC/POS byte sequences, printers reachable over TCP, Bluetooth, USB, or the system spooler, and browser environments where you get WebUSB and Web Serial and nothing else.
I ended up extracting this into an open-source library, Universal Thermal Printer, precisely because I had written variations of the same transport-juggling code more than once. It prints ESC/POS to thermal printers and PDF to A4, over TCP, Bluetooth, USB, the system spooler, WebUSB, or Web Serial, and it runs in Node.js, Bun, Electron, Expo, and the browser.
Two lessons from that work are worth stating even if you never touch the library.
Always have an A4 fallback. Thermal printers jam, run out of paper, and get unplugged. If your only output path is 80mm ESC/POS, a paper jam stops trade. Being able to fall back to a PDF on a normal office printer keeps the shop open.
Print from a rendered document, not from your domain objects. Build an explicit receipt representation — lines, alignment, barcode, totals — and render that. Otherwise every layout tweak means touching sale logic, and eventually someone changes a tax field to fix a printing bug.
Hospitality changes the shape of the problem
Retail is largely transactional: scan, total, pay, print. Hospitality adds a dimension that reorganises the model — an order has a long life before it becomes a sale.
A table is opened. Items are added over an hour. Some are sent to the kitchen and some to the bar. A course is fired. Something is comped. The party splits the bill three ways. Only at the end does any of it become an invoice.
The model that survives this is orders as an event stream, with everything else as a projection over it:
- The kitchen display is a projection filtered to preparable items, ordered by fire time
- The table view is a projection grouped by seat
- The bill is a projection with pricing and tax applied
- The sale is what you get when the bill is settled
Trying to model that as a mutable orders row with a status column produces a table with thirty nullable columns and a status field that means six different things. I have written that table. I do not recommend it.
Split billing is the specific case that punishes a weak model. If order lines are immutable events, splitting a bill is a grouping operation over them and you can do it three different ways on request. If you have been mutating a single order total, splitting it is a rewrite.
Money: minor units, always
Store money as integer minor units — paisa, cents — and never as a float.
# Wrong. 0.1 + 0.2 != 0.3, and your daily till reconciliation will drift.
total = 0.0
for line in lines:
total += line.price * line.quantity
# Right. Integer arithmetic, exact, and the rounding is explicit.
total_minor = sum(
round_half_up(line.unit_price_minor * line.quantity)
for line in lines
)
Decimal is acceptable and better than float, but integers make the rounding decision impossible to skip, and rounding is the part that matters. Decide once, in one function, whether you round per line or per invoice — then never do it anywhere else. Tax rounding on a fifteen-line receipt can differ by a rupee depending on where you apply it, and a rupee a day compounds into a reconciliation dispute nobody can unwind.
What I would tell someone starting one
If you take four things from this:
- Decide the offline story before you write a model. It determines your primary keys, your ID generation, and your entire sync design. Retrofitting it means rewriting all three.
- Generate IDs on the client. One UUID column turns every sync operation into a safe, idempotent upsert and eliminates a whole category of duplicate-record bugs.
- Make facts append-only and derive the rest. Sales and stock movements are immutable records of things that happened. Stock levels, totals, and reports are projections. Facts do not conflict; mutable aggregates do.
- Never store money as a float, and centralise your rounding.
None of this is novel distributed-systems theory. It is mostly the discipline of separating what happened from what we currently believe, which is the distinction a retail counter will expose within a week of going live.
The systems that keep working are the ones where a terminal can lose the network for a day, keep trading, and reconcile cleanly when it comes back — without anyone having to decide which of two conflicting numbers to believe.