Shipmind Labs

Order per entity, not per queue, in an offline write queue

· 8 min read

An offline write queue has to honour two rules that pull against each other: two changes to the same record have to arrive in the order the user made them, and one stuck change should not hold unrelated work hostage behind it. A single global FIFO queue gives you the first and not the second. A queue that fires everything at once gives you the second and quietly corrupts the first. Neither shape is a tuning problem. Both are missing the same fact.

The constraint: the queue does not know what a change touches#

We build mobile clients where writing offline is ordinary rather than exceptional, role-specific apps for couriers and warehouse staff, where the signal goes away between a loading dock and a basement and the work carries on without it. What you notice first in that setting is that ordering is not a property of the queue. It is a property of a pair of changes.

Edit the title of note 42, then delete note 42: the order decides what the server ends up holding. Upload a photo, then star a different note: the order does not matter, and insisting on it means the star waits behind a multi-megabyte upload on a cell edge. A queue that sorts only by the time a change was enqueued has thrown away the one fact that separates those two cases, which is what the change touches. That fact lives in the app's domain model and not in the transport, and no amount of poking at the request body gets it back.

So in our queue (https://github.com/shipmindlabs/offlinequeue) the caller names it:

typescript
const entityOf = (operation: Operation) =>
  (operation.payload as { note?: string }).note ?? operation.kind;

const queue = new OfflineQueue({
  storage: keyValueStorage(AsyncStorage),
  send,
  entityOf,
  concurrency: 4,
});

entityOf returns a string, and that string is the lane. Changes that return the same string go out in the order they were made; changes that return different strings do not wait for each other. When the app supplies nothing, the default is operation.kind, which orders a change against every other change of its kind. That default is correct and coarse on purpose: it does not reorder anything, and it serialises two unrelated notes behind each other, which is exactly the trade you should be able to see and then improve on.

A lane stops at the first change that has to be retried#

The tempting optimisation inside a lane is to step over the change that failed and try the next one, so the flush gets some work done. That optimisation is the bug:

typescript
const seen: string[] = [];
const q = queue(async (operation) => {
  seen.push(operation.kind);
  return { result: "retry", reason: "offline" };
});
await q.enqueue("title", { note: "42", title: "Dinner" });
await q.enqueue("remove", { note: "42" });

const report = await q.flush();

assert.deepEqual(seen, ["title"], "the delete does not overtake the edit it follows");
assert.equal(report.retrying, 1);

If the title edit stays on the device and the delete goes out, the server sees a delete for a note whose edit arrives afterwards. What happens next is not ours to decide: depending on how the write is implemented, the late edit either fails on a missing row or recreates the record the user deleted. Users report both outcomes as the app losing their work, and neither one reproduces without the exact pair of timings that produced it.

So head-of-line blocking inside a lane is not a flaw to be optimised away. It is the guarantee, stated in terms of a storage state. You bound the cost of it by making lanes narrow, not by stepping over the head. The change at the head is left pending with a nextAttemptAt in the future, the delay doubles from baseDelayMs under a maxDelayMs ceiling, and the jitter is drawn per change so that every phone that lost the same cell tower does not retry in the same instant.

Lanes never wait for each other, up to a limit#

Once the entity is named, the stuck lane is only as wide as the record it belongs to:

typescript
await q.enqueue("edit", { note: "1", text: "first" });
await q.enqueue("edit", { note: "1", text: "second" });
await q.enqueue("edit", { note: "2", text: "unrelated" });

const report = await q.flush();

assert.equal(report.sent, 1, "the unrelated note went out");
assert.equal(report.retrying, 1);
assert.deepEqual([...seen].sort(), ["1", "2"], "the second change to note 1 waited");

The second edit to note 1 waited, because it had to. Note 2 went out, because nothing said it should not.

A flush runs several lanes at once, up to concurrency, four by default, and that bound is there for the radio and the server rather than for correctness:

typescript
const lane = gated(2);
for (const note of ["1", "2", "3", "4"]) await lane.q.enqueue("edit", { note });

const flushing = lane.q.flush();
await settle();
assert.equal(lane.started.length, 2, "two entities are on the wire, not four");

lane.open();
const report = await flushing;
assert.equal(report.sent, 4, "the rest went out as the first two finished");

The property worth stating out loud, because it is what makes the setting safe to change: concurrency limits how many unrelated changes are in flight together, and it is not a way for two changes to one entity to overtake each other. Raising it cannot break ordering. It can only press harder on a weak connection and on the server. Lowering it to one is still correct, since entities then go one at a time, slowly, and in order.

Parking is the one place we give up ordering on purpose#

The transport answers with one of three outcomes: done, retry, or rejected. The distinction between the last two is what keeps a lane from freezing for ever. retry means try again, so the lane stops. rejected means the server will not accept this change, so stopping the lane behind it helps nobody:

typescript
await q.enqueue("title", { note: "42", title: "" });
await q.enqueue("star", { note: "42" });

const report = await q.flush();

assert.deepEqual(seen, ["title", "star"], "the lane carries on past the parked change");
assert.equal(report.rejected, 1);
assert.equal(report.sent, 1);

This is worth being honest about rather than presenting as free: stepping past a parked change does reorder it against the rest of its lane. We accept that, because the alternative is a lane that is dead until the app is reinstalled, and because a refusal is not something the queue can fix. A change that merely ran out of attempts is parked the same way, for the same reason, since a delay that doubles for ever is still a queue that does not stop trying.

What the queue owes you in exchange is that the parked row stays in storage, keeps its parkedReason and lastError, and is reachable from a screen through failed. From there the ways out are retry(id), which returns it to pending, and discard(id), which removes it. Deliberately not a second enqueue, which would hand back the parked row and look like it had worked. The idempotency key is generated on the device and does not change across retries, so a change that sits parked for days and is then retried is still recognisable to the server as the same change.

What it costs to run#

The one real decision is the granularity of entityOf, and it has a failure mode on each side. Too coarse and the app serialises work that has no relationship, every note behind every other note, which is what the kind default does. Too fine and two changes that genuinely depend on each other land in different lanes and can arrive backwards, which is the original bug with extra steps. The rule we apply is to make the entity the thing the server's write actually contends on, usually the row being updated. When a change touches two entities, it is named after the one whose ordering cannot be lost, or it is split into two changes.

The rest is cheap and worth knowing. Every state transition is persisted, so a flush writes to storage; enqueueing a key the queue already holds changes nothing and therefore writes nothing. leaseMs has to be longer than the slowest request the transport permits, since it is the period after which an attempt is presumed to have died with its process; load reclaims those on the way in. And FlushReport carries sent, rejected, exhausted, retrying and remaining as separate fields precisely because a badge, a spinner and a "needs your attention" list are three different questions.

None of this needs a device to test. The ordering rules above are assertions about which requests a fake transport saw, and in which order, which is why they are regression tests rather than things we re-learn in the field.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com