An unreadable store is not an empty queue
A queue that persists every change before enqueue resolves is at its most dangerous on the first write after a read that failed. Take a failed read for "the device holds nothing" and the next write saves a one-element array over everything that survived the night. The edits a user made in a basement with no signal are gone, and nothing records that they existed. The library whose entire purpose is not losing a change becomes the thing that loses it.
We build offline-capable mobile work across the field apps we deliver, role-specific apps for couriers, warehouse staff and store operations, where a write happens where the network is worst and matters most. The pattern that keeps coming back is this one, and it is not a retry policy or a backoff curve. It is the storage contract. offlinequeue is our React Native queue for exactly this, and the rule it is built around is that two storage failures which look identical from the call site must not be handled identically.
Two failures that read the same and are not#
A store can fail in two ways. It can answer with something that is not a queue: a key collision, a value half-written when the process died mid-save, something another library put under the same key. Or it can not answer at all, because the native module is not ready, the file is locked, or the device is out of space in a way that breaks reads.
The first case has told us something true. There is no usable queue on this device. The second case has told us nothing at all. Conflating them is the data-loss path, so the adapters in src/storage.ts only turn the first one into an empty queue:
/**
* Anything that is not an array of operations is not a queue. A store holding
* something else — a key collision, a half-written value — reads as empty
* rather than as a queue of nonsense.
*
* A store that could not answer at all is a different failure and is not
* handled here: it has said nothing about what the device holds, so it is not
* turned into an empty queue.
*/
function decode(raw: string | null | undefined): Operation[] {
if (!raw) return [];
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
return Array.isArray(parsed) ? (parsed as Operation[]) : [];
}An absent value, a string JSON.parse cannot read, and a parsed value that is not an array all decode to an empty queue. That is a deliberate concession. A corrupt value is bad, but an app that will not start because of one is worse, and the user can at least make the edit again. What decode never sees is a read that threw, because there is no try around the store call:
export function keyValueStorage(store: KeyValueStore, key = defaultKey): Storage {
return {
async load() {
return decode(await store.getItem(key));
},
async save(operations) {
await store.setItem(key, JSON.stringify(operations));
},
};
}Four lines of body, and the whole policy sits in them. getItem rejecting propagates. mmkvStorage is the same shape over MMKV's synchronous getString/set, where a missing key arrives as undefined instead of null, which is why decode accepts both. Both adapters read one key,"offlinequeue" unless a second argument names another, and both answer the same Storage interface, so the choice of store is one line at construction. That also means both can be tested with the same suite, and a store that throws is part of it:
const unavailable = (): never => {
throw new Error("storage unavailable");
};
test("AsyncStorage: a store that cannot be read says so", async () => {
const storage = keyValueStorage({ getItem: unavailable, setItem: async () => {} });
await assert.rejects(() => storage.load(), /storage unavailable/);
});The queue stays unloaded, so the next write retries the read#
An adapter that rethrows is only half of it. The queue has to do something with the rejection other than log it, and what it does is stay unloaded. OfflineQueue.load sets its loaded flag only after the awaited storage.load() has returned, so a failed read leaves the instance exactly as it was: not loaded, with its in-memory operations untouched.
That matters because every method that can write starts the same way. enqueue,flush,retry,discard,prune and recover all begin with await this.load(), which repeats the read while the queue is unloaded and rethrows if it fails again. Persisting is only reached after that call returns. So a transient read failure does not produce an empty queue that then gets saved. It produces a call that fails, and a next call that reads again:
test("enqueueing over a failed read does not overwrite the stored queue", async () => {
const cells = memoryStorage();
const overnight = queue(cells, offline);
await overnight.enqueue("note", { text: "survived the night" });
const next = queue(throwsOnceOnRead(cells), accepted);
await assert.rejects(() => next.enqueue("note", { text: "fresh" }), /storage unavailable/);
assert.deepEqual(
(await cells.load()).map((operation) => operation.payload),
[{ text: "survived the night" }],
"the queue nobody could read was not written over",
);
// It stayed unloaded, so the next call reads again — and adds to what it
// found rather than to nothing.
await next.enqueue("note", { text: "fresh" });
assert.equal((await cells.load()).length, 2);
assert.equal(next.outstanding.length, 2);
});The same property is what keeps a flush honest. A flush over a queue it could not read attempts nothing and writes nothing, and the flush after it reads again and sends what was there. Recovery behaves the same way. A row the process died on stays in-flight rather than being reclaimed blind, and it goes back to pending only through a recover that actually managed to read. Reclaiming a lease on the basis of a read that failed would be guessing about a request that may still be on the wire.
One more detail belongs to this part of the contract. One read answers every caller waiting on it. Two concurrent enqueue calls do not issue two reads, because a second read settling on top of the first loses whatever was enqueued in between.
A failed write is not swallowed either#
The write side is the mirror image. save is a JSON.stringify of the whole array, and the queue chains each save behind the previous one so two flushes cannot interleave their writes. The chained promise is kept alive with its own catch, but the caller is handed the save's own promise, so a write that failed reaches whoever asked for it rather than disappearing into the chain. Swallowing it would lose a change silently, which is the exact failure the library exists to prevent.
That leaves one path with no caller to throw to: a flush started by the platform telling us the device came back online. It gets an explicit error channel instead.
start(connectivity: Connectivity, onError?: (error: unknown) => void): Unsubscribe {
this.stop();
this.#unsubscribe = connectivity((online) => {
if (!online) {
this.cancel();
return;
}
// A flush that starts from an event has no caller to throw to, so a read
// or write that failed goes to onError or nowhere.
this.#run().catch((error: unknown) => onError?.(error));
});
return () => this.stop();
}"Or nowhere" is deliberate wording. If you pass start a connectivity source and no onError, storage failures during background flushes are invisible to you. Passing it is not optional in anything we ship.
What it costs to run#
The price of this line is that a storage failure stops the queue instead of degrading it. There is no mode where the app keeps accepting writes into memory while storage is unavailable, because that mode is a promise the library cannot keep. A change held only in memory is one the OS can take at any moment, and enqueue resolving would be a lie.
So the cost lands on the UI. enqueue can reject, and the screen that called it has to say so. The save button has to fail visibly rather than optimistically, and the retry is the user tapping again or the app calling again. In practice this is probably the same handling an offline write already needs, and the rejection is rare and self-clearing: the next call reads again, and a store that came back answers.
The second cost is test discipline. A storage contract with two failure modes has to be tested against both, for every adapter, which is why memoryStorage exists alongside the real two. It copies the array on load and on save, so it behaves like a store that round-trips rather than one that hands back a live reference. Wrapping it in a store whose first read fails and whose later reads work is how the unreadable path gets asserted at all.
The rule is small enough to state in one sentence, and it is worth stating that way: a read that failed is not knowledge that the device holds nothing. Everything else in the queue, backoff, leases, idempotency keys, parked rejections, is about what happens to a change after it is safely on the device. This is about not throwing it away before any of that can help.