Skip to content
Kamau Wanyee

Offline queues that dont lie

By ··
·7 min read
#posts#offline-first#mobile#systems

An offline queue earns trust when the process dies halfway through a write. The next launch must know what work existed, how far it got, and what it can safely try again.

The dangerous queue has a clean interface. Add a task. Flush the queue. See an empty list. That emptiness can mean success, or it can mean the client discarded the only record of unfinished work.

I use a stricter rule: a flush is an attempt to drain work. It does not declare work complete. A task leaves durable storage only after its handler reaches a terminal success state, or after an explicit discard policy accepts the loss.

A write has more than one finish line

A mobile write often crosses several boundaries:

local artifact -> queued task -> upload -> remote processing -> product acknowledgement

Each step can succeed while the next step waits. An upload response can arrive before remote processing finishes. The process can die after the server accepts an object and before the client records that fact. The network can disappear while local files remain available.

The queue needs a record of the work between those boundaries. A single pending flag cannot answer whether the client should upload a file, submit its remote key for processing, retry a request, or show a permanent failure.

Persist the task, not just the intention

The queue manager persists serialized tasks in AsyncStorage. A task carries its status, progress, retry count, configuration, input, error, execution history, next retry time, and handler-specific state. The manager chains persistence writes so a later update cannot be replaced by an older write that was still in flight.

That state is useful only when it records progress at the same granularity as the work. A multi-step submission needs checkpoints such as:

CheckpointQuestion it answers
Upload filesWhich local inputs reached remote storage?
Process recordingsWhich remote objects became a usable result?
Upload photosWhich supporting files remain?
Submit recordHas the final product request succeeded?

The current submission handler stores completed recording and photo identifiers, processing results, and remote object keys in task state. When a task resumes, it can skip completed stages instead of starting from an empty progress bar.

The same principle applies to any queued write. Persist the position that makes the next attempt safe. A retry that starts from ignorance may duplicate work, overwrite a result, or lose the relationship between a local artifact and its remote counterpart.

Give every task a real identity

The queue creates a task ID for each item. It also derives an idempotency key for submission work from the resource identity. If an active task with that key already exists, adding the same work returns the existing task ID.

That check closes one common race: a user taps submit, the first request takes too long, and the UI queues a second copy. The client can still receive duplicate requests after a process dies or a server times out. The server-side operation must tolerate the same identity too. Client deduplication reduces duplicates; it cannot replace backend idempotency.

Model the queue as a lifecycle

The queue has explicit statuses: pending, running, failed, completed, permanently failed, and cancelled. Its snapshots also expose useful phases such as ready, backing off, blocked, and running.

PhaseMeaningSafe next action
Pending or readyWork can run when scheduling permitsExecute once
RunningA handler owns the attemptWait, or recover after a deadline
Backing offA retry is scheduled for laterPreserve state until release
BlockedA dependency has not completedResolve the dependency
Permanently failedAutomatic retry has endedShow, repair, or explicitly retry
CompletedThe handler reported successRemove from the active queue

This vocabulary gives the UI and operators something better than queue.length. Age, retry count, progress, last error, next retry time, and checkpoint state explain why work is waiting.

Let error class choose the retry

Retries should follow the error, not a fixed counter alone. The queue classifies network failures, timeouts, server errors, rate limits, authentication failures, validation failures, and other client errors. Each class carries a retryable decision.

Network and timeout errors can remain retryable until a task reaches its maximum age. Server and rate-limit errors use backoff. Validation and other non-retryable client failures move toward permanent failure. The retry manager adds delay and jitter so a reconnecting group of clients does not hit the service at once.

That policy needs an exit. The current queue gives tasks an age limit, a retry limit where appropriate, and a permanent-failure state. Infinite retry hides a broken task. Immediate deletion hides the evidence needed to repair it.

Recovery starts at boot

The queue loads serialized tasks when it initializes. It then inspects persisted state for due retries, stale pending work, expired tasks, and attempts left in RUNNING after the process disappeared.

Health recovery can:

  • release a retry whose backoff has elapsed;
  • requeue an orphaned running task after its recovery grace period;
  • expire work past its configured maximum age;
  • leave a pending task blocked behind a dependency;
  • start processing only when the connection is usable.

The same recovery runs when the app returns to the foreground, when network quality improves, and during the operating system’s background task. The queue does not need the previous JavaScript process to survive. It needs a durable task record and a handler that can resume from that record.

Flush should preserve state

The implementation of flushQueue has two jobs:

async function flushQueue() {
  await requeueFailedTasks({ preserveState: true })
  await processReadyTasks("manual_flush")
}

The important argument is preserveState. Requeueing changes scheduling status. It does not erase checkpoints, remote object keys, attempt history, or task identity.

This distinction has a simple test: create a permanently failed task with partial upload state, flush it, and assert that the task becomes pending while its checkpoint state remains. The queue has that test. The test protects the rule against a future cleanup shortcut.

Tradeoffs are part of the design

Durable local state costs storage and creates retention work. The queue bounds its size, removes old completed work, and gives tasks maximum ages. Those limits need product policy: how long can an unfinished artifact remain, who can discard it, and what does a permanent failure mean to the user?

Checkpointed retries also leave remote side effects. A server may have accepted an object before the client timed out. The next attempt must use stable identity and tolerate an already-present object. Local state reduces uncertainty; it does not make a distributed write atomic.

This is why I treat recording as a distributed system on one phone. Capture, native audio, disk, network, and server processing each have their own timing and failure modes. Recording is a distributed system

The queue’s boundary

Use a queue for writes that can outlive the screen, the connection, or the process. Keep the handler small enough to checkpoint. Make terminal success explicit. Give permanent failure a visible repair path.

Do not use a queue to make an unclear success policy look reliable. Decide what the product means by complete, then encode that finish line in the handler and its checkpoints.

The useful question is simple: if the process dies now, what fact lets the next process continue safely? If the answer lives only in memory, the queue is pretending to remember.