Why we shipped offline-first when no one was asking
An engineering deep-dive. IndexedDB, conflict resolution, and why convenience matters more than power.
When we surveyed restaurant owners about what they wanted in a POS, "offline mode" was never in the top 10. They asked for things like better reports, easier menu setup, faster checkout, more payment options.
We shipped offline-first anyway. Here's why · and how.
The hidden cost of online-only
Customers don't ask for offline mode because they don't think about it · until the WiFi goes down, and then they think about nothing else.
We measured this at 12 pilot restaurants for 90 days. Average internet downtime: 41 minutes per restaurant per month. That's 0.09% downtime · which sounds small, until you realize it tends to happen at the worst possible time (storms, lunch rush, large parties).
Average lost revenue per outage event: ฿4,200. Spread across the month, that's ฿1,800 lost per restaurant per month · roughly equal to our entire Growth plan price.
The technical setup
LEMON is a web app · runs in any browser. We use the browser's native storage layer:
- IndexedDB · primary storage for orders, menu, customers (~50MB capacity)
- localStorage · for session state and small lookups (~5MB capacity)
- Service Worker · intercepts network requests, falls back to IndexedDB when offline
- Sync queue · in-memory + persisted to IndexedDB · replays when online
The hard part isn't storing data offline. The hard part is what to do when two devices both took orders for the same table while one was offline.
Conflict resolution
Imagine: two iPads at the same restaurant. Tablet A goes offline (kitchen WiFi flakes). Tablet A takes 14 orders. Tablet B (front of house) is still online. Tablet B takes 8 orders.
When Tablet A comes back online, what happens?
Most syncing systems use last-write-wins · whoever syncs last overwrites the other. This is wrong for a POS · you'd lose orders.
We use append-only event logs. Every order is an immutable event with a timestamp + device ID. When Tablet A reconnects, it doesn't overwrite anything · it just appends its 14 events to the shared log. The server merges by timestamp.
// simplified · real code has device clocks, idempotency keys
type OrderEvent = {
id: string; // UUID, generated on device
deviceId: string;
timestamp: number; // device clock
payload: Order;
};
async function sync(localEvents: OrderEvent[]) {
await fetch('/api/sync', {
method: 'POST',
body: JSON.stringify({ events: localEvents }),
});
// server appends, returns nothing for already-seen IDs
}What we got wrong (twice)
Version 1: We trusted device clocks. A tablet's clock got reset (battery died, then booted with default time). Its events landed in the year 2018. Reports broke. We added server-side timestamp validation.
Version 2: We let any device sync any data. A staff iPad in store A briefly connected to store B's session (shared owner account, wrong tab open). Cross-store contamination. We added store-scoped sync tokens.
Why we keep doing it
Last year, our largest customer · a 4-store chain in Phuket · had a regional ISP outage during dinner service. 3 hours, 0 orders lost, ฿180,000 in sales. Their owner, K. Chai, told me later: "I didn't even know the internet was down. The receipts kept printing."
That's why we ship offline-first. Not because customers ask for it · because they shouldn't have to.
Get the next issue in your inbox.
One email every 3 months. No spam.