Every point-of-sale demo works. The developer clicks Add to cart, clicks Pay, a receipt appears, everyone applauds.

Then you install it in a shop on Street 271, the fibre gets cut by roadworks, and a queue of six people watches a spinner.

This series builds a POS that keeps selling anyway. Not a toy — the real constraints: stock that has to stay honest, sales that must never be lost or double-counted, and KHQR payments that genuinely cannot be verified without a connection.

What we are building

  • A till that takes sales with the network completely off
  • A server that accepts those sales later without creating duplicates
  • Stock that reconciles correctly when several tills come back at once
  • An honest answer to what happens to card and KHQR payments offline

The stack is TypeScript throughout: Express and TypeORM on PostgreSQL, a browser client using IndexedDB. The ideas transfer to any stack — the hard parts here are not framework-specific.

Why "offline-first" is a design decision, not a feature

Most systems are built online-first and have offline bolted on later. That ordering is the problem. Online-first code assumes the server can answer right now: it asks for the next invoice number, checks stock, gets a total back. Every one of those assumptions has to be unpicked to work offline, and unpicking them late means rewriting the core.

Offline-first inverts it. The local device is the source of truth for what happened. The server is the source of truth for what it all means.

That single sentence drives every decision in this series:

QuestionOnline-first answerOffline-first answer
Who generates the sale ID?The serverThe client
When is a sale "real"?When the server respondsWhen it is written locally
What is the stock level?Ask the serverServer-derived, client-estimated
What if the request fails?Show an errorQueue it. It is already saved

The three failures that actually happen

Before writing code, be precise about what goes wrong. Vague requirements produce vague systems.

1. The connection drops mid-sale

The obvious one. The cashier has scanned six items and the network dies. The sale must complete, print, and take cash.

2. The connection pretends to work

Far nastier, and far more common in practice. The wifi is associated, the phone shows bars, but packets are not moving. HTTP requests hang for 30 seconds before timing out.

A naive client shows a spinner for 30 seconds per sale. This is worse than being offline, because being offline is at least detectable. We will handle it with aggressive timeouts rather than waiting for the browser's default.

3. Two tills sell the last item

Shop has one unit left. Till A and Till B both sell it while offline. Both come back online.

You cannot prevent this. Physics is against you — the information did not exist in either place. What you can do is detect it, record it accurately, and surface it to a human. A system that silently lets stock go to −1 is lying; a system that refuses the sync is unusable. We will do neither.

The architecture

┌──────────────── TILL (browser) ────────────────┐
│                                                │
│   UI  ──►  local sale write  ──►  IndexedDB    │
│                                     │          │
│                                  outbox        │
│                                     │          │
│                              sync worker       │
└─────────────────────────────────────┼──────────┘
                                      │  batched, idempotent
                                      ▼
┌──────────────── SERVER (Express) ──────────────┐
│   POST /sync/sales                             │
│      ├─ dedupe on client-generated id          │
│      ├─ insert sale + lines (one transaction)  │
│      └─ recompute stock from movements         │
│                          │                     │
│                     PostgreSQL                 │
└────────────────────────────────────────────────┘

Three properties matter here, and they are worth stating explicitly because everything later depends on them.

Sales are append-only. A sale is a fact: at 14:32 this cashier sold these items. Facts do not conflict. Two tills creating sales at the same time produce two sales, not a merge conflict. This is why the design works at all.

Stock is derived, never stored as a number you update. The moment you UPDATE products SET stock = stock - 1 you have created a value that two offline clients will disagree about. Instead we store movements and compute the level. Movements are append-only too.

The client generates identity. A sale gets its permanent id on the device, before the server has ever heard of it. That is what makes retries safe.

Client-generated IDs

If the server assigns ids, an offline sale has no id, and a retry cannot be distinguished from a new sale. So the client assigns.

Not a random UUIDv4 — those are unordered, which makes them a poor primary key in Postgres (random inserts fragment the B-tree). Use UUIDv7, which embeds a timestamp and therefore sorts roughly by creation time:

src/shared/id.ts
ts
import { uuidv7 } from "uuidv7";

/**
 * Sale ids are generated on the till, not the server. That is what makes a
 * retry idempotent: the second attempt carries the same id as the first, so
 * the server can recognise it as a duplicate rather than a new sale.
 *
 * v7 rather than v4 because it is time-ordered — random ids fragment the
 * index, and on a table that only ever grows that cost compounds.
 */
export const newId = (): string => uuidv7();

A useful side effect: because v7 sorts by time, a batch of queued sales inserts sequentially even though it arrived hours late.

Detecting "offline" honestly

navigator.onLine is close to useless. It reports whether the device has a network interface, not whether your server is reachable. A till connected to a router with a dead upstream reports true.

Check reachability instead, with a short timeout:

src/client/net/reachable.ts
ts
/**
 * navigator.onLine lies: it is true whenever a network interface exists, which
 * includes a wifi router whose upstream is down — the exact failure this system
 * has to survive.
 *
 * The timeout is deliberately short. A cashier with a queue would rather be
 * told "offline" in 2 seconds and keep selling than wait 30 for a browser
 * default that ends in the same place.
 */
const PROBE_TIMEOUT_MS = 2000;

export async function serverReachable(baseUrl: string): Promise<boolean> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);

  try {
    const res = await fetch(`${baseUrl}/health`, {
      method: "GET",
      cache: "no-store",
      signal: controller.signal,
    });
    return res.ok;
  } catch {
    return false;
  } finally {
    clearTimeout(timer);
  }
}

Two seconds is not arbitrary. It is roughly the point at which a person waiting in a queue starts to feel that something is broken.

What the cashier sees

Design decision worth making early: never block the sale on network state.

The till always writes locally and always prints. Connection status is information, not a gate:

  • 🟢 Synced — everything queued has reached the server
  • 🟡 Working offline — 4 sales queued — plain, unalarming, accurate
  • 🔴 Sync failed — needs a human; queued sales are still safe

The yellow state is the important one. It must not look like an error, because it is not one. The system is working exactly as designed. A cashier who thinks the till is broken will stop using it and reach for a paper book — and then you have lost the data for real.

What is coming

PartSubject
1Why offline-first, and the architecture ← you are here
2The data model: movements, not stock levels
3Selling offline — IndexedDB, the outbox, and printing
4Sync: idempotency, batching, and the last-item problem
5KHQR and card payments when there is no connection

Part 2 builds the schema, and starts with the single decision that makes or breaks this design: never storing a stock level.


This series documents the approach behind SuiteWright POS, a point-of-sale system built for Cambodian retail. If you are planning something similar, tell us about it.