Skip to content
warehouseopsshopifyreactoffline

Offline-first receiving: Why warehouse Wi-Fi is always terrible

6 min readBy Tigran

ByTigranFounder, EcomHubPublished Updated

The universal warehouse Wi-Fi problem

Every warehouse we have ever worked in has a Wi-Fi dead zone. It is usually at the back, near the receiving door, under the metal mezzanine, or in the aisle with the tall racks full of product that happens to be made of metal. The IT team knows about it. The ops manager knows about it. Somebody has been "getting a mesh node quote" for about eighteen months.

None of that matters to the receiver standing there with a phone, a pallet of inbound goods, and a scan-in task that is blocking the rest of the shift. If the receiving app needs a live connection to work, the work stops.

For a long time the industry answer to this was a handheld scanner running a thick client that talked to a local server over a wired connection. That works, but it is expensive, proprietary, and fragile in its own way. What we have found works better for the kind of merchants we serve — small and mid-size operators doing Shopify-based fulfillment — is a web application that is designed from day one to keep working when the network is not there.

The architecture

At the core there are four moving pieces, each doing one thing.

A service worker owns the network. Every outbound request the app makes is intercepted. Read-path requests for data that has already been cached are served from the cache immediately; background revalidation fires if the network is up. Write-path requests — scans, receipts, put-aways — are not sent to the server from the UI directly. They go into a queue.

An IndexedDB store is the queue. A pending_actions object store holds every mutation the user has taken since the last successful sync, each one a small JSON document with a client-generated UUID, a timestamp, and the minimum set of fields the server needs to replay it. A second object store, catalog, holds the expected inbound PO lines, the SKU-to-barcode map, and the active wave manifest — everything the app needs to operate without the server.

The UI is optimistic. When a receiver scans a barcode, the UI confirms the scan instantly. The user sees green, the count increments, the task ticks forward. Under the hood, the scan has been written to IndexedDB and enqueued for sync. The UI never waits on the network.

A sync worker runs on an interval and also on the online event. It pops items off the queue, posts them to the server in order, and handles the response. Success removes the item from the queue. A non-retryable error (401, 422) pushes the item into a dead-letter queue the receiver is notified about. A retryable error (5xx, network failure) leaves the item on the queue for the next attempt.

This is not novel architecture. It is what every serious offline-first product does. What we have learned is mostly about the small decisions inside this skeleton.

Why a PWA, not React Native

We have shipped React Native apps before, and we will again. For warehouse receiving UIs, we have landed consistently on PWAs.

The reasons are practical.

Warehouse staff turnover is high. The onboarding cost of "scan this QR code, hit Add to Home Screen" is measured in seconds. The onboarding cost of pushing a TestFlight invite, waiting for Apple to wake up, walking the new hire through accepting it, and then debugging why the device is stuck on an old build is measured in a shift.

Device fleets are mixed. Merchants give their receivers whatever spare device is in the drawer — a five-year-old Android, an iPhone SE, sometimes a rugged handheld that runs Chrome. A PWA runs on all of them with one build.

Updates land instantly. When we fix a bug in the scan-conflict resolution path, we push to the CDN and the next time the receiver opens the app they have the fix. No store review, no forced update prompt, no weeks of fleet segmentation.

The cost of choosing a PWA is that we do not get access to the full native API surface. For receiving that is fine — the camera, vibration, the Barcode Detection API where it is available, offline storage, service workers, and a display: standalone PWA shell cover everything we have needed.

Conflict resolution across devices

The hardest bug in offline-first receiving is not offline itself. It is two devices going offline, doing conflicting work, and then coming back online.

The concrete case: a big inbound PO, two receivers working from opposite ends of the same pallet. They are both scanning the same SKU into the same PO line. Both devices are offline. Each of them posts five scans to their local queue. Then the Wi-Fi returns.

If we naively sync each device's queue in isolation, we end up with ten scans on a line that was only supposed to receive eight. Someone has to fix it.

The pattern we have settled on is server-side reconciliation with client-side eventual consistency. Each scan has a client-generated UUID and carries the device's vector clock for that PO line. The server, when it receives the scans, does not blindly apply them — it merges them into the authoritative count and returns the reconciled state. The client then replaces its local count with the server's.

For the overcount case specifically, the server accepts the scans but flags the PO line as over_received, and the receivers see a warning banner the next time they look at the line. They then resolve it manually — pull a unit back off, update the inbound qty if the vendor actually shipped more than the PO. The software does not try to be clever. It surfaces the conflict and lets a human decide.

We have tried smarter strategies. Last-write-wins loses scans. Per-device partitioning works until the receivers swap devices. Giving each device a pre-allocated scan budget forces coordination the receivers do not want to do. Surfacing the conflict and letting the humans resolve it is the least clever option and the one that causes the fewest tickets.

Testing

We test offline in three concentric circles.

Chrome DevTools. The Network tab's throttling profiles let you drop to Slow 3G or fully offline with one click. Every feature gets built and tested against "offline from the start", "offline mid-task", and "network flaps every five seconds." The flapping case catches a surprising number of sync-queue bugs.

A simulated warehouse in the office. We have a script that runs a real Chromium instance, drives it with Playwright against a local build of the app, and toggles the network on and off at random intervals for thirty minutes. The test passes if the sync queue drains completely within sixty seconds of the final network restoration and the server-side record matches the client's optimistic count. This catches races the human eye cannot.

Actual warehouse visits. Twice a year, once before major launches, one of us goes to the merchant's warehouse and works half a receiving shift. You cannot debug warehouse software from an office. You need to see the device held in a glove, used under a cold-storage light, handed off between receivers mid-scan. Nothing we have built has survived the first warehouse visit unchanged.

A cheat sheet of patterns we reuse

Three IndexedDB patterns we reach for on every project.

Monotonic integer keys for the queue, UUIDs for the business entity. The queue store uses autoIncrement: true so we can pop items in insertion order. Each item's payload includes a UUID that the server uses for idempotency. Getting this wrong — using the UUID as the key, or using the integer as the business identifier — leads to pain when you start replaying.

Versioned object stores. Every object store has a _schema_version on each record. When we change the shape of a stored entity we bump the version and run a migration on open. Without this, an upgrade is a coin flip.

A write-through cache for the catalog, with a TTL. The catalog (PO lines, SKUs, barcodes) is read-heavy and changes in bursts. We write it to IndexedDB on every successful fetch and serve from IndexedDB first, refetching in the background if the last-fetched timestamp is older than fifteen minutes. The receiver never sees a loading spinner for data they have seen before.

None of this is exotic. It is the quiet plumbing that decides whether a receiver in a dead-zone aisle is standing there staring at a spinner or quietly getting their work done. That gap — between "it works at my desk" and "it works where the work happens" — is the gap most warehouse software never crosses.