A delivery app for a basement with no signal
An offline-first PWA with two persistence layers, and error handling that refuses to treat a lost connection like a server rejection.
- Client
- Jabal Al Noor Pharmacy
- Industry
- Pharmacy retail
- Outcome
- Full offline task flow
The problem
Delivery agents lose connectivity in lifts, car parks and stairwells — mid-task, holding a phone and a customer medicine. An app that fails a status update in that moment is worse than paper.
Two persistence layers
A Next.js PWA with two stores, chosen for different jobs:
| Layer | Store | Why |
|---|---|---|
| Reads | TanStack Query to localStorage, 24h max age | A cold offline start still renders the assignment list |
| Writes | IndexedDB via Dexie | Writes include photos, and localStorage is string-only |
Offline, an agent can mark picked up, failed, and delivered-with-photo, and the UI updates immediately.
The error handling is the design
On failure the app branches on why:
- Network error — the optimistic state is deliberately kept and the mutation is queued
- Any other error — roll back
A server rejection and a lost connection are not the same event and must not be treated alike.
One storage detail worth knowing
Captured photos are stored as a raw ArrayBuffer rather than a Blob, because Blobs do not survive IndexedDB structured-clone in every environment — including jsdom, which would have made this untestable. A File is reconstructed at drain time.
Draining the outbox
On reconnect the queue drains oldest-first with three-way triage:
- Conflict (400/403/404 — reassigned, already terminal, invalid) — dropped permanently and surfaced to the UI, because it can never succeed on replay
- Network error — increment attempts and break, since we are still offline; resume on the next
onlineevent - Anything else (transient 5xx) — increment attempts and continue to the next entry
Auto-retry stops at five attempts per entry, leaving it for a manual tap-to-retry so a wedged request cannot loop forever.
What is explicitly out of scope
The primary trigger is the browser online event rather than the Background Sync API. That is a deliberate accommodation: iOS Safari PWAs do not support Background Sync, so the service-worker sync message is a best-effort secondary on Chrome and Android only.
The design doc lists true cross-platform background sync guarantees as out of scope rather than pretending otherwise.