Shipmind Labs

A read that threw is not an empty queue.

That one distinction keeps an offline-first app from erasing work it never saw. A store that answered with something unusable and a store that could not answer at all look identical at the call site, and treating them the same is how a user's pending writes disappear on the next enqueue.

In our open-source offlinequeue library the contract splits them deliberately.

The decode side is forgiving. The key-value and MMKV adapters read one key ("offlinequeue" unless a second argument names another) and hand the raw value straight to decode. An absent value, whether null, undefined or an empty string, decodes to an empty queue. A string JSON.parse cannot read decodes to an empty queue. A value that parses but is not an array decodes to an empty queue. A half-written record from a process killed mid-save does not brick the app, it starts clean.

The read side is not forgiving at all. A getItem or getString that throws is caught nowhere in the storage layer. It propagates. And load marks the instance loaded only after the awaited storage read has returned, so a failed read leaves the queue unloaded with its in-memory operations untouched.

That ordering is the whole guarantee. Every mutating entry point (enqueue, flush, retry, discard, prune, recover) begins by awaiting load, which retries the read while the instance is still unloaded and rethrows if it fails again. Persistence is reached only after that call returns. So a one-element queue can never be stringified over everything that survived the last run. No write happens until a read has succeeded.

The write side mirrors it. Save is a stringify of the whole array, and persistence chains each save behind the previous one, returning that save's own promise. The caller hears the rejection while the chain itself stays alive through a catch, so one failed write does not poison every write after it. A flush triggered by a connectivity event has no caller to tell, so its error routes to the error handler passed at start.

Our take: durability bugs in local persistence almost never come from the write path. They come from an error on the read path being flattened into a default value. "No data" is a legitimate state. "We don't know" is not, and if your storage layer cannot say the second one, every recovery path downstream is guessing.

The in-memory adapter copies the array on both load and save, for the same reason: a store that hands out a live reference is a store whose contract you cannot reason about.

If you maintain a local queue, cache or draft store, it is worth knowing what your code does today when the read itself fails, rather than when it simply returns nothing.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com