Skip to content
← Journal

Engineering

Offline-first mobile: syncing with no signal

How WTN App worked with no signal: SQLite on the phone, an append-only event stream per chain, conflict rules per role and a QR code handoff.

9 November 2026 · 6 min read

WTN App was a React Native app for digital waste transfer notes in the UK, with Postgres on the server. A load followed a chain of signatures from producer to carrier to receiver. A driver had to be able to take a load, sign it and hand off with no signal, and the chain had to reconcile when any party came back online. The app is no longer maintained, but the techniques still apply.

The code in this post, fields included, is simplified illustration, not the real code. A non-technical explanation is in An app that works with no signal.

SQLite on the phone

In an online-first app, a write waits for the server, so with no network you have nothing. With SQLite on the device, the app writes to a local database and the interface reads from it. Syncing becomes a separate task that runs when there is a network.

SQLite suits this for two reasons. The data is on disk, so it survives a restart or the operating system killing the app. You also get transactions: a new event and the updated local view commit together, so an app that stops halfway leaves no half-finished work.

An append-only event stream per chain

Every chain had its own event stream, which you can only add to. A change is a new event, and existing events stay. That suits offline work for a few reasons.

Two devices updating the same row offline give you a conflict in which one overwrites the other. Two devices appending events give you two events, and you can keep both and interpret them afterwards.

Retrying is safe. If the network drops in the middle of an upload, the client does not know whether the server got the event. If every event has an id generated on the device, the client simply sends it again and the server ignores what it already has.

Order only has to hold within a chain. You need no global order across all data, and the odds of concurrent writers per chain are small. Do not count on the device clock for this, because it can be wrong. A per-device counter or a reference to the previous event tells you more, and the server records the order in which it received the events.

You also get an audit trail, which is handy for a document where it later matters who signed what and when.

// Simplified illustration, not the real code.
type Role = "producer" | "carrier" | "receiver";

type ChainEvent = {
  id: string; // generated on the device, makes retries safe
  chainId: string;
  actor: { userId: string; role: Role };
  type: "fields_edited" | "signed";
  payload: Record<string, unknown>;
  recordedAt: string; // device clock, for information only
};

A projection on the server

The server derived the current state of each chain from the events. A projection like that is a function from events to state, written out to ordinary tables that feed dashboards, searches and exports. Because the events remain the source, you can rebuild a projection when you change the logic.

A phone with no network needs a view too. In this kind of setup you fold the same events locally into a state in SQLite. That local state is provisional, and the server has the last word.

Conflict rules per role

The rules were simple: producers edit producer fields, carriers edit carrier fields, and nobody can re-sign for someone else.

General merge strategies such as last write wins are a poor match for a document with legal weight, because they silently drop a change. If you divide the fields per role, writes from different roles no longer overlap, and a whole class of conflicts disappears. What remains is the same role on two devices. You still need a rule of your own for that.

// Simplified illustration, not the real code.
const FIELDS_BY_ROLE: Record<Role, string[]> = {
  producer: ["wasteCode", "quantity", "siteAddress"],
  carrier: ["vehicleReg", "collectedAt"],
  receiver: ["receivedAt", "acceptedQuantity"],
};

function isAllowed(event: ChainEvent): boolean {
  if (event.type === "signed") {
    return event.payload.role === event.actor.role;
  }
  const allowed = FIELDS_BY_ROLE[event.actor.role];
  return Object.keys(event.payload).every((field) => allowed.includes(field));
}

The client can apply those rules too, so the interface does not offer a forbidden action. The check that counts happens on the server, and because the client works offline, the server can only refuse an event afterwards. Your interface has to be able to show that something that seemed to succeed locally was later not accepted.

The QR code handoff

The producer showed a short-lived QR code with the chain ID and an HMAC of the producer’s snapshot. The carrier scanned it with no network.

A QR code works between two phones with no signal, without pairing them first. With an ID and an HMAC, the code stays small and so easy to scan on a little screen outdoors. The carrier’s event then refers to exactly that version of the producer’s data. Once the events from both sides are on the server, it can check the HMAC against the snapshot, whoever came online first.

// Simplified illustration, not the real code.
const snapshot = canonicalJson(producerFields(chain));
const mac = hmacSha256(key, `${chain.id}.${expiresAt}.${snapshot}`);
const qr = encodeQr({ chainId: chain.id, expiresAt, mac });

A few things to watch if you build this yourself. An HMAC works over bytes, so both sides must serialise the snapshot in exactly the same way. An HMAC is also not a digital signature: anyone with the key can compute it. What the check proves therefore depends on who holds the key. If you need to show per signer who signed something, look at asymmetric signatures with a key per device. The short validity limits how long a screenshot of the code is usable, but offline that check depends on the device clock. The server can only enforce it strictly afterwards.

A sync badge on every screen

Every screen showed a badge with three states:

A single “synced” indicator would lump the last two together, while they mean different things to a driver. Offline-first hides the network from the write, so the interface has to say somewhere how far a change has got.

What else to plan for

A phone that has been offline for a long time can send events in an old format. Your server has to keep accepting old versions, and every event needs a version number. Also expect old apps to receive new events back, and decide what they do with them.

Streams only ever grow. Per chain that stays limited, because a chain is finished at some point. If your streams have no end, you will have to think about snapshots.

Read next

What's your idea?

Tell me what you're trying to build. I try to reply within one working day.