The offline write path is a storage problem, not a network problem
Reading offline is a cache: keep the last response, show it again, refresh when the signal returns. Writing offline is a different problem, and it is where mobile apps break. A change made in a tunnel has to survive the operating system killing the app, a network that comes back weak enough to invite a hundred failed requests, a request that reached the server just before the connection dropped, and a change the server is never going to accept. Those are four separate failures with four separate fixes, and none of the fixes is trying again later.
We build role-specific mobile apps for people who work in places with bad signal — couriers, warehouse staff, store operations — where the network is not a background concern but the working condition. Someone in a service lift or between racking is offline for a minute at a time, repeatedly, all day. The app has to keep accepting input during that minute, and everything it accepted has to arrive exactly once afterwards. We factored the part that is identical in every such app into a small package we maintain, offlinequeue (https://github.com/shipmindlabs/offlinequeue), and the thing worth writing down is that almost none of the difficulty turned out to live in the networking code.
Where the difficulty actually lives
The transport layer of an offline queue is a function: take an operation, return one of three outcomes. That is the whole of it. Everything hard sits on the other side — in what gets written to durable storage, when it gets written, and what state a row is left in when the process dies in the middle of a request. Restate the four failures in those terms and each one stops being a question about connectivity:
A change held in memory dies with the app, so a change has to become a stored row before it becomes a request.
Uncapped retries flatten the battery and hand the outage back to the server, so when a change is next eligible has to be a value on the row rather than a timer in a process that may not exist in a minute.
An attempt that was in flight when the OS killed the app is stuck forever, so in flight has to be a recoverable state with an expiry, not a moment inside a function call.
One rejected change blocks every good one behind it, so stopped has to be its own state with its own way out.
Persist first, resolve second
await queue.load();
await queue.enqueue("note", { title, body });The ordering here is the feature. enqueue resolves after the change is durable, not after it has been accepted for sending. If it resolved any earlier, the UI would be free to render a saved state for something that exists only in a heap the OS is about to reclaim — the edit is gone and nothing says so. load is its counterpart on the way in, and it does more than read: it reclaims attempts abandoned by a previous run before the app gets a chance to start new ones.
The three outcomes are the contract
const queue = new OfflineQueue({
storage: keyValueStorage(AsyncStorage),
send: async (operation) => {
const response = await fetch(url, {
method: "POST",
headers: { "Idempotency-Key": operation.idempotencyKey },
body: JSON.stringify(operation.payload),
});
if (response.ok) return { result: "done" };
if (response.status >= 400 && response.status < 500) {
return { result: "rejected", reason: await response.text() };
}
return { result: "retry", reason: `status ${response.status}` };
},
});done and retry are obvious. rejected is the one that matters, and it is the one most queues do not have. It means the server will never accept this change, so retrying is pointless and holding the queue behind it helps nobody. Notice where that decision is made: once, at the transport boundary, by the code that knows what the API's status codes mean. The queue itself never inspects a response. That is what keeps the classification honest — a validation error and a gateway timeout are different kinds of event, and the only place with enough context to tell them apart is the adapter you wrote for your own API.
The idempotency key in the header is the other half of the same snippet. It survives restarts, which is the only reason a retry after a crash is safe. A timeout is a request whose outcome you do not know; retrying it without a stable key is how a payment becomes two identical charges.
Backoff is a field, and jitter is per change
The delay doubles, is capped, and carries jitter drawn per change. The cap is the battery: an exponential that grows without limit is still a queue, but a phone that regains a weak signal and fires a hundred immediate retries is a dead phone by lunchtime. The jitter is the server. Every device that lost the same cell tower comes back at the same instant, and if they all compute the same delay from the same attempt count, the recovery is a thundering herd that hands the outage straight back to the service that just came up. Drawing the jitter per change, and storing it, means two changes on the same phone do not even move in lockstep with each other.
Storing the next eligible moment rather than sleeping until it arrives is what makes this survive a process kill. Dispatch becomes a read of the clock against a field. Nothing is lost when the app is swapped out, because nothing was being held.
In flight is a state with an expiry
An attempt records when it started. A change still in flight when the process died is recognised on the next start and returned to pending once its lease has run out — retried under the same key, and, importantly, not while the first request may still be on the wire. That last clause is why the lease exists at all: without it the choice is between abandoning work forever and duplicating requests that have not finished.
load reclaims on the way in and recover does the same on demand; both are safe to call again, since a reclaim changes nothing the second time. The one number to tune is leaseMs, and the rule is to set it longer than the slowest request your transport will allow. Too short and you race your own in-flight request; too long and a genuinely stuck change waits longer than it needs to before being picked up again.
The parking lane is a screen, not a log line
for (const operation of queue.failed) {
if (operation.parkedReason === "exhausted") {
await queue.retry(operation.id);
}
}queue.failed is everything that stopped, which is exactly what a "these did not sync" screen lists. A parked change keeps what stopped it in lastError and says which of the two things happened in parkedReason: rejected needs a person, exhausted ran out of maxAttempts and may only need a better network. A parked row carries no nextAttemptAt, because nothing is coming for it.
There are two ways out, and both are deliberate acts. discard(id) drops the change. retry(id) puts it back with its attempt count reset, under the original idempotency key — so a change the server did in fact receive is still not applied twice. Parking exhausted separately from rejected matters for the same reason the third outcome exists: a delay that doubles forever is still a queue that never stops trying, and it deserves to stop and say so.
What it costs to run
The storage adapter is the one piece that has to be real, and choosing it is a line:
keyValueStorage(AsyncStorage) // anything with getItem/setItem
mmkvStorage(new MMKV()) // the same two operations, synchronous
memoryStorage() // tests, and a first run before storage is wiredA second argument names the key, because two queues in one app must not share one. Failure modes are asymmetric on purpose: a store that cannot read starts empty, a store that cannot write throws. An unreadable queue is bad and an app that will not start is worse — but a write that silently does nothing is the original failure wearing a disguise.
Because storage and transport arrive as functions, the package never imports anything from React Native, and every behaviour above is exercised in a plain test runner rather than on a device. Our test suite covers the queue and the storage adapters separately, and it runs in CI like any other TypeScript. That is not a stylistic preference. Crash recovery and lease expiry are precisely the behaviours you cannot reliably reproduce by hand on a phone, so if they are only testable on a device they are, in practice, untested.
Two things still cost real effort. Order is kept within a kind, not globally — an edit and a delete applied backwards is a corrupted record, but one stuck photo upload should not hold up a note, so different kinds proceed independently and you have to decide what your kinds are. And the parking screen is product work: someone has to design what a person sees when a change of theirs will never be accepted. The queue can tell you which changes those are and why. It cannot tell the user what to do about it.
The pattern generalises past mobile. The same queue runs in a web app, a worker, or a server-side test of your own sync logic, because none of the four failures is really about phones. They are about a process that can die between deciding to do something and knowing whether it happened.