Skip to content
Arun Arivanandam
Writing

A React data layer that survives a bad network

Notes from building an offline-first pharmacy app for shops on 2G, where the connection is not an edge case but the normal operating condition.

3 min read

Most front-end code treats the network as reliable with occasional failures. In the shops we build for, that is backwards: the connection is intermittent by default and good by exception. A counter assistant scanning a strip of tablets cannot wait to find out whether the request landed.

This is what we settled on after two rewrites.

Writes go to the device first

Every user action writes to IndexedDB synchronously from the UI’s point of view, and separately gets appended to an outbox. The UI reads only from local storage, so it never waits on a request.

export async function recordSale(sale) {
  const local = { ...sale, id: ulid(), syncState: 'pending' };
  await db.sales.put(local);
  await db.outbox.add({ op: 'sale.create', payload: local, at: Date.now() });
  return local;
}

Two details that matter more than they look:

  • IDs are generated on the client. A ULID is sortable and collision-resistant, so the server can accept the client’s ID as canonical. No temporary IDs to reconcile later.
  • The outbox is a separate store. Keeping intent separate from state means a failed sync never corrupts what the user can see.

The outbox drains on its own schedule

A single worker walks the outbox in order and stops at the first failure, because operations are frequently causally related — you cannot apply a stock adjustment for a batch that has not been created yet.

async function drain() {
  const pending = await db.outbox.orderBy('at').toArray();

  for (const item of pending) {
    const result = await send(item);
    if (result.status === 'retry') break;       // network — try again later
    if (result.status === 'conflict') {
      await quarantine(item);                    // needs a human
      continue;
    }
    await db.outbox.delete(item.id);
  }
}

Failures split into three buckets, and conflating them was our first rewrite. A network timeout should retry forever. A validation error should never retry. A conflict needs a person.

Conflicts get resolved, not overwritten

Last-write-wins is the default everyone reaches for and it is wrong for stock counts. Two shops counting the same batch offline produce two legitimate numbers, and the later timestamp is not more correct.

Each document carries a small version vector — one counter per device that has touched it. On sync the server compares vectors:

ComparisonMeaningAction
Incoming dominatesClient is strictly aheadAccept
Stored dominatesStale writeReject, send current
Neither dominatesGenuine concurrent editQuarantine for review

Quarantined items surface in the UI as a short list the pharmacist clears at close of day. It is roughly two items a week per store, which turned out to be an entirely acceptable amount of human involvement.

Designing for the conflict case is what made this trustworthy. Shops stopped keeping a paper backup once they could see what the system was unsure about.

What the UI does differently

The interface has to be honest about sync state without being noisy about it. What worked:

  • A single persistent indicator showing pending-operation count, not a toast per request.
  • Rows that have not synced yet get a subtle marker rather than a spinner. Nothing is loading; it is saved, just not shared.
  • No optimistic-then-revert. Because local state is the source of truth, there is nothing to revert.

The bit I would do differently

We built the sync engine before we understood the conflict rules, which meant the second rewrite touched everything. If I started again I would spend the first week writing down, for every document type, what two concurrent edits should produce — and only then write code.

The transport is the easy part. The semantics are the work.