The requirement arrived as one sentence from a shop owner: “When the internet goes, the till still has to work.”

That sentence is a distributed systems problem wearing retail clothes. The moment a client can commit a sale without the server, you have two writers, an unreliable link, and money on the line. Every classic failure mode shows up: duplicate writes, lost writes, stale reads, and the special horror of charging a customer twice for one basket of groceries.

This is how the Sortorium desktop POS handles it - a Tauri 2.0 shell around a React front end, a local SQLite database, and a Django backend that refuses to be lied to.

Why a desktop app at all

A PWA with a service worker can go offline. I chose a native shell anyway, for reasons that are operational rather than architectural:

  • The machine is a shop counter PC, often old, often running one thing all day. A Tauri binary is a few megabytes over a system webview instead of a bundled Chromium, and the memory difference is visible on the hardware these shops actually own.
  • Local data wants a real database. IndexedDB is workable; SQLite with a Rust process in front of it is better when the same rows are read on every keystroke of a product search.
  • Hardware. Receipt printers, cash drawers and barcode scanners are far less painful from a native process than from a browser sandbox.
  • Credentials. A token in browser storage is a token in browser storage. In Tauri it can live behind the Rust boundary, and eventually in the OS keychain.

Tauri 2.0’s permission model also means the front end declares what it may touch, so the React layer cannot reach the filesystem just because someone imported the wrong helper.

Two databases, one truth

The single sentence the whole design hangs on:

The client proposes. The server decides.

SQLite on the till is a cache and a queue, never a source of truth. It holds two categories of data, and keeping them separate is what stops the design collapsing:

Purpose Authority
Cache tables (products, stock_snapshot, customers) Let the UI work offline Server. Overwritten on every pull.
Outbox table (checkout_outbox) Hold writes the server has not accepted yet Client, until acknowledged.

The cache is disposable. If it is corrupted, wrong, or a week stale, the fix is to drop it and pull again. The outbox is not disposable - it is the only record of a sale that happened in the physical world but has not yet been told to the server. Those two things need different treatment, and merging them into one “local state” store is how offline apps end up losing sales.

The outbox

When a cashier completes a sale offline, the client does not “queue a request”. It commits an intent - the complete, final payload the server will eventually receive:

async function commitSale(basket: Basket): Promise<LocalSale> {
  const payload = {
    branch_id: session.activeBranch.id,
    lines: basket.lines.map(toLine),
    payments: basket.payments,
    idempotency_key: crypto.randomUUID(),   // generated exactly once, here
    client_committed_at: new Date().toISOString(),
  };

  await db.execute(
    `INSERT INTO checkout_outbox
       (id, tenant_subdomain, branch_id, payload, status, attempts, created_at)
     VALUES (?, ?, ?, ?, 'pending', 0, ?)`,
    [payload.idempotency_key, session.tenant, payload.branch_id,
     JSON.stringify(payload), payload.client_committed_at],
  );

  return renderReceipt(payload);   // the customer leaves with their goods
}

Three details in there are load-bearing.

The payload is stored whole, not reconstructed later. If the outbox stored a foreign key to a local sale record and rebuilt the request at push time, then a schema change, a cache refresh or a partially-written row could change what gets sent. A serialized payload is a promise about exactly what will hit the server.

idempotency_key is generated once, at commit time. Not at push time. Not per attempt. This is the entire safety property, and the most common way to get it wrong is to generate the key in the HTTP layer, where a retry helpfully makes a fresh one.

The row is inserted before the receipt prints. If the app dies between the two, the customer has goods and no receipt - recoverable. The other order means the customer has a receipt for a sale that no longer exists anywhere.

Draining it

Reconnection is where implementations get clever and then get burned. This one is deliberately boring:

async function pushOutbox() {
  const rows = await db.select<OutboxRow[]>(
    `SELECT * FROM checkout_outbox
      WHERE status = 'pending' AND tenant_subdomain = ?
      ORDER BY created_at ASC`,        // strict FIFO
    [session.tenant],
  );

  for (const row of rows) {
    try {
      const sale = await api.post('/pos/checkout/', JSON.parse(row.payload));
      await markSynced(row.id, sale.id);
    } catch (err) {
      if (isNetworkError(err)) return;             // stop; try the whole queue later
      if (isRetryable(err)) { await bumpAttempts(row.id); continue; }
      await markFailed(row.id, err.code, err.detail);   // needs a human
    }
  }
}

Why FIFO and not parallel? Throughput is not the constraint - a busy till produces a few hundred sales a day, not a few thousand a second. What FIFO buys is a cash-register ordering that matches the cashier’s mental model, a debuggable log when something goes wrong, and no self-inflicted lock contention from ten of your own requests fighting over the same hot SKU. Parallel drain is an optimisation for a problem this system does not have.

Why does a network error abort the loop rather than skip the row? Because if the link is down, every remaining row will fail too. Continuing just burns the retry budget and inflates attempts counters that later look like real failures.

The server half of the contract

An idempotency key on the client is a wish. It becomes a guarantee only because of what the server does:

@transaction.atomic
def checkout(*, user, branch_id, lines, payments, idempotency_key):
    assert_user_branch_access(user, branch_id)

    existing = Sale.objects.filter(idempotency_key=idempotency_key).first()
    if existing:
        return existing

    levels = (StockLevel.objects
              .select_for_update()
              .filter(branch_id=branch_id, product_id__in=[l.product_id for l in lines]))
    verify_available(levels, lines)          # raises InsufficientStock

    try:
        sale = Sale.objects.create(..., idempotency_key=idempotency_key)
    except IntegrityError:
        # Lost the race against a concurrent first attempt with the same key.
        return Sale.objects.get(idempotency_key=idempotency_key)

    record_payments(sale, payments)
    apply_stock_movements(sale, levels)
    return sale

The part worth dwelling on is that select_for_update and the idempotency key solve different problems, and it took me one production scare to internalise that they are not interchangeable.

select_for_update is about concurrency: two cashiers, two different sales, one remaining unit. The row lock serialises them so the second sees the true quantity and fails cleanly instead of driving stock to -1.

The idempotency key is about duplication: one sale, sent twice. No amount of locking helps, because the second request is perfectly valid in isolation. Only identity does.

And the check must be backed by a unique constraint, not by filter().first() alone. That read is not a lock; two genuinely concurrent first attempts can both see nothing and both proceed. The IntegrityError branch above is the one that makes it correct - the loser of the race fetches the winner’s sale and returns it. If your idempotency column has no unique index, you do not have idempotency, you have a race with good intentions.

The crash window

The question I get asked most: what if the server commits, then the network dies before the client marks the row synced?

The client wakes up, sees pending, and posts again. The server finds the existing sale by key and returns it. The client marks synced. Nothing is decremented twice.

This is precisely why the client is not allowed to be the authority on whether a sale succeeded. “I think it worked” is not a state you can build on across a partition. The server’s uniqueness constraint is the arbiter, and the client’s job is only to keep asking until it gets a definitive answer.

The complementary failure - client crashes before inserting the outbox row - is a sale that never existed digitally. That is a real loss, and it is why the insert precedes the receipt and the confirmation UI. The window is narrow and, unlike a double-charge, it is visible to the cashier standing there.

When the sale cannot land

The genuinely hard case is not a network error. It is this: the till sold three units offline, and while it was offline another branch’s transfer or an online sale consumed the stock. On replay the server correctly returns INSUFFICIENT_STOCK.

There is no clever resolution here. The goods have physically left the shop. Options that all look tempting and are all wrong:

  • Force the sale through and let stock go negative. Now inventory lies, and every downstream report inherits the lie.
  • Silently drop the row. Revenue disappears with no audit trail. This is the worst option and also the easiest to write.
  • Auto-retry forever. Stock is not coming back. The queue jams behind a row that will never succeed.

What it does instead: the row moves to failed, keeps the server’s reason, and appears in a queue the cashier or manager has to clear - reconcile the stock discrepancy, void, or accept and adjust. failed is a state, not a deletion. An offline-first system that cannot show you what it failed to sync is asking you to trust it about the one thing you cannot verify.

The security bits people skip

Offline-first quietly widens the threat surface, and two of these only exist because of the local database:

Branch tampering. The desktop payload carries branch_id. A modified client can carry a different one. So assert_user_branch_access runs server-side inside the transaction, checked against the user’s role assignment - not against anything the client claimed. Frontend branch scoping is UX; the server assertion is the control.

Cross-tenant cache bleed. Outbox and cache rows are keyed by tenant subdomain, and switching tenant or logging out wipes that tenant’s rows. Without this, a pending sale committed under tenant A could be pushed with tenant B’s Authorization and X-Tenant-Subdomain headers - a cross-tenant write, from your own client, that no server-side check would catch because the request would look entirely legitimate.

Replay from a stolen device. The idempotency key that protects against accidental duplication also bounds deliberate replay: re-posting a captured payload returns the original sale rather than creating a new one.

What I would change

Three things, in the order I would do them:

Delta sync. The pull is currently a full catalogue refresh. It is fine at a few thousand SKUs and will not be at fifty thousand. updated_since plus tombstones for deletes is the obvious fix; I have not needed it yet, and saying so is more useful than pretending the current design scales further than it does.

Dead-letter visibility. failed rows are visible in the app but do not alert anyone. A failed row older than an hour is a business problem, and it should page rather than wait to be noticed.

OS keychain for tokens. They currently live in Tauri’s store. The keychain is the right home, and Tauri 2.0’s plugin ecosystem makes it a contained change.

The shape of it

If you take one thing from this, take the split:

Optimistic on the client, pessimistic on the server. The till assumes the sale will work, because the customer is standing there. The server assumes nothing, because it is the only component that can actually see the truth. The outbox is the bridge, and the idempotency key is what makes crossing it repeatable.

Everything else - the SQLite schema, the Tauri commands, the retry policy - is implementation. That contract is the design.