Here is a stock bug that throws no error, passes every single-device test, and still loses inventory.
The counter model
The obvious model is a number on the product:
product.stock = 12; // on each sale: product.stock -= quantitySold; On one device it works. Now add a second till, and let both work offline.
- Both tills start with
stock = 12. - Till A sells 2, so it stores
10. - Till B sells 3, so it stores
9. - Both reconnect and sync their new value.
The server keeps 10 or 9, depending on which arrives last. Five units left the shop. The system recorded two or three. Nobody sees an error.
The ledger model
Instead of storing the current number, store every change as a movement with a signed delta:
class StockMove { const StockMove(this.id, this.productId, this.delta, this.reason); final String id; final String productId; final int delta; // -2 sold, +24 delivered final String reason; // sale, refund, delivery, count } Current stock is the sum of the movements:
int stockFor(String productId, Iterable<StockMove> moves) => moves .where((m) => m.productId == productId) .fold(0, (sum, m) => sum + m.delta); Same scenario:
| Movement | Delta |
|---|---|
| Opening stock | +12 |
| Till A sale | −2 |
| Till B sale | −3 |
| Stock | 7 |
12 − 2 − 3 = 7, whichever till reports first.
Why it works
Movements are append-only, like sales. No till ever overwrites another till's data; each one only adds rows. Syncing append-only data is just addition, so the order of arrival doesn't matter.
This is the same idea accountants have used for centuries: you don't erase the balance, you record transactions and derive the balance.
Making it fast enough
Summing thousands of rows on every screen is slow. So keep a cached count per product and treat it as derived data:
- update the cached count after each movement is stored
- rebuild it from the ledger at start-up (or after sync)
If the cache and the ledger ever disagree, the ledger wins.
Stock takes and corrections
When a stock take finds 7 on the shelf and the ledger says 9, don't overwrite the number. Record a correction:
StockMove(uuid(), productId, -2, 'count'); You keep the reason, the history stays auditable, and the correction syncs like any other movement.
Where else this applies
Anything that is a running total edited from more than one place: wallet balances, loyalty points, seat counts, budgets. If two offline devices can change it, store the changes, not the total.
Further reading: Offline POS architecture in Flutter on our blog also covers single-write checkouts, outbox sync and idempotent uploads.
Have you hit the "last sync wins" bug in production?
