outbox_queue 0.1.1 copy "outbox_queue: ^0.1.1" to clipboard
outbox_queue: ^0.1.1 copied to clipboard

A durable outbox for offline-first apps — queue writes while the network is gone, replay them in order when it returns, with backoff, lanes and dead-lettering.

outbox_queue #

CI

A durable outbox for offline-first apps. Queue writes while the network is gone, replay them in order when it comes back — with exponential backoff, independent lanes, and dead-lettering for the ones that will never succeed.

Pure Dart, no dependencies. Works in Flutter, on the server, and in tests without a device.

One refused sale holds up its own lane, and nothing else

That figure is drawn by dart run tool/figure.dart, which runs a real Outbox against a scripted handler and plots what happened — so it cannot show behaviour the queue does not have.

final outbox = Outbox(store: FileOutboxStore(Directory('.outbox')));

outbox.register('sale', (op) async {
  final ok = await api.postSale(op.payload, idempotencyKey: op.id);
  return ok ? OutboxVerdict.done : OutboxVerdict.retry;
});

// Returns once the sale is on disk — not once it reaches the server.
await outbox.enqueue(type: 'sale', lane: 'sales', payload: {'total': 47.80});

// Call when connectivity returns, on resume, or on a timer.
await outbox.drain();

The problem #

A till takes a payment in a shop with no signal. A field app records an inspection in a basement. The write has already happened as far as the person is concerned — the customer has their change and has left. The only honest thing the app can do is store it and send it later.

Doing that properly turns out to involve more than a list:

  • It has to survive the process being killed mid-queue.
  • Replays have to stay in order, or a delete arrives before the update it was meant to follow.
  • One write the server will never accept must not block everything behind it forever.
  • When the outage ends, every device in the fleet reconnects at the same moment. If they all retry on the same schedule they arrive as one spike.

Delivery is at-least-once #

Worth being blunt about, because it decides how you write your handlers.

A request can succeed on the server and still fail to say so — the response is lost, the socket dies, the process is killed between the send and the acknowledgement. From the client, that is indistinguishable from a request that never arrived. So the queue retries, and the server sees the write twice.

That is not a defect to be engineered away; it is what a network is. Handlers must be idempotent. Every operation carries a stable id across all retries and restarts, exactly so the server can recognise a replay:

outbox.register('sale', (op) async {
  final ok = await api.postSale(op.payload, idempotencyKey: op.id);
  return ok ? OutboxVerdict.done : OutboxVerdict.retry;
});

Lanes #

Operations in one lane are strictly ordered, and the first one that cannot be sent stops the lane — skipping past it would deliver a later write before an earlier one.

Different lanes are independent. That is the point: a sale the server keeps rejecting should not hold up an unrelated stock adjustment queued behind it.

await outbox.enqueue(type: 'sale',  lane: 'sales', payload: {...});
await outbox.enqueue(type: 'stock', lane: 'stock', payload: {...});

Use one lane per stream of work that must stay in order with itself. If nothing needs ordering, put everything in its own lane and nothing ever blocks.

Verdicts #

A handler returns one of three things, and choosing correctly is what keeps the queue healthy:

Verdict Meaning Use for
done Sent. Drop it. 2xx
retry Might work later. Back off and try again. Offline, timeout, 5xx
drop Will never work. Dead-letter it now. 4xx, validation failure

Returning retry for a permanent failure is the mistake that hurts: the operation blocks its lane until it exhausts maxAttempts, and everything behind it waits.

Backoff #

Exponential, with full jitter — a random point between zero and the ceiling, rather than the ceiling itself.

The jitter is not decoration. Devices on a flaky connection lose it together and regain it together, so a deterministic backoff has the whole fleet retry in the same instant and the server takes the spike. Full jitter spreads the reconnection out, at the cost of any single retry being less predictable.

Outbox(
  store: store,
  backoff: Backoff(initial: Duration(seconds: 2), maxDelay: Duration(minutes: 10)),
  maxAttempts: 8,
);

Dead-lettering #

An operation is parked, not deleted, when it exhausts maxAttempts, when a handler returns drop, or when no handler is registered for its type. It stays readable through loadDeadLettered() so you can show it, export it, or fix it by hand.

The last case matters more than it sounds: without it, shipping a build that forgets to register a type would wedge the lane permanently on an operation nothing can send.

Storage #

OutboxStore is an interface with two implementations in the box:

  • FileOutboxStore — durable, no dependencies. Rewrites the queue on every change and renames the new file over the old one, so a torn write is never observable.
  • InMemoryOutboxStore — for tests, and for callers that genuinely do not need to survive a restart.

The rewrite-everything approach is right for a queue of dozens and wrong for one of hundreds of thousands. When you outgrow it, implement OutboxStore over sqflite or Drift — it is six methods, and nothing else changes.

Both stores serialise their mutations. Lanes drain concurrently and share one store, so two read-modify-write cycles interleaving would otherwise let the second silently undo the first.

Showing progress #

outbox.stats.listen((s) => setState(() => _unsent = s.total));

OutboxStats separates pending (sendable now) from waiting (held by a backoff) so a UI can say "retrying in a moment" rather than "stuck".

Install #

dependencies:
  outbox_queue: ^0.1.0

Tests #

dart test

24 tests, covering ordering, lane isolation, backoff scheduling, dead-lettering, restart durability, torn files, and concurrent drains.

Licence #

MIT

2
likes
160
points
17
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A durable outbox for offline-first apps — queue writes while the network is gone, replay them in order when it returns, with backoff, lanes and dead-lettering.

Repository (GitHub)
View/report issues

License

MIT (license)

More

Packages that depend on outbox_queue