A warehouse is one of the worst places to run a mobile app. Steel racking blocks signal, the loading dock sits outside Wi-Fi range, and the person holding the phone is wearing gloves and has a pallet waiting. If a scan hangs, the work stops, and the count gets scribbled down to be entered later, away from the shelf. Or it doesn't get entered at all.

Offline-first design is meant to stop that. Most guides cover the easy half, which is caching data so screens load without a connection. This post is about the hard half: what happens to the work people do while they're offline, and how that work gets back to the server without being lost, duplicated or credited to the wrong person.

We'll use RapidStock as the running example. It's a Flutter inventory app we built for an Australian B2B supplier, used for scanning stock, recording what was used against a job, running stocktakes and raising orders. It runs on iOS and Android from one codebase, and its orders check out through the client's Magento store.

Key takeaways

  • Decide offline behaviour feature by feature, and show users the list. "The app works offline" is never true of every screen.
  • Cache reads, but queue intents. Store what the user did and when, not the totals you expect the server to end up with.
  • Record who did each action. Shared devices are normal on a warehouse floor, and the sync has to replay each person's work under their own account.
  • A connection type isn't a connection. Check that the server is reachable before you start a sync.
  • Assume every queued request will be sent twice. Make the server safe to receive it twice.

Why field and warehouse apps are different

A consumer app can treat lost signal as an edge case. A field or warehouse app can't, because losing signal is part of the job. Three things make this category harder than it looks.

The environment fights the network. Metal shelving, basements, rural job sites and moving vehicles all drop or degrade connections. Signal is often present but useless: one bar, a captive portal on site Wi-Fi, or a connection that drops halfway through a request.

The user can't wait. People scanning stock are working against the clock and often against a supervisor. They need a scan to confirm instantly, with feedback they can feel through gloves. RapidStock vibrates on every successful scan for exactly that reason.

The data has consequences. A missed stocktake line becomes a wrong reorder. A duplicated usage record bills a job twice. A count credited to the wrong user breaks the audit trail. Offline support that loses or doubles data is worse than none, because nobody notices until the numbers don't add up.

Strong fit

Teams that scan, count, inspect or record work in places with patchy signal: warehouses, workshops, job sites, farms, field service.

Strong fit

Apps on shared devices, where several people use one phone or scanner across a shift.

Think first

Apps where most actions need live data, such as live pricing, stock reservation or payment. Offline support there is narrow and needs clear rules.

Probably not needed

Content apps and dashboards. Caching for fast loads helps; a full write queue usually doesn't earn its cost.

Start with a feature-by-feature offline map

The most useful step on RapidStock came before any sync code: a table with one row per feature, saying whether it works offline and what happens when the connection returns.

The app shows that table to users too. Open the offline sheet and you see which features work without a signal and which don't, instead of finding out halfway through a task. This is the list from the shipped app, with a few related features grouped.

RapidStock's offline map, as shown in the app

Feature
Without signal
When signal returns
App launch
Works Opens from cache
Fetches the latest theme
Sign in (existing users)
Works For anyone who has signed in before
Normal session
Store list
Works After the first load
Refreshes
Product lists, images and detail
Works From cache
Refreshes
Scan a product
Works For products already on a list
Normal
Record usage against a job
Works Queued
Syncs
Order requisition
Works Queued
Syncs
Submit a stocktake
Works Queued
Syncs
Email
Works Queued
Syncs
Search the full catalogue
Needs signal
Works
Direct order
Needs signal
Works
Start a stocktake, generate a stocktake order
Needs signal
Works
Sign in (new users), reset password
Needs signal
Works
Admin settings and profile changes
Needs signal
Works

Look at search and scan. Scanning works offline because it matches a barcode against product lists already on the phone. Searching the full catalogue doesn't, because the catalogue lives on the server and is far too large to mirror on every device. Workers pick up the rule quickly: build your list while you have signal, then scan against it anywhere.

Starting a stocktake needs a connection because the server has to open it and fix the stock position it counts against. Submitting counts doesn't, because those counts belong to a stocktake that already exists. Direct ordering needs a connection because it checks out through the store. Sign-in for brand-new users needs one because there's nothing on the device to check them against yet.

Draw this map before you write any sync code, agree it with the client, and put it in the app. Every later decision gets easier once everyone knows what the app promises.

Reads: cache what people work from

Reading data offline is the well-understood half. Flutter's own architecture guide describes the main options: fall back to local data when the network fails, read local data first and then refresh it from the server, or read only from local storage and sync it separately.

For field apps we lean towards the second pattern. Show the cached version straight away, then replace it when fresh data arrives. The worker never stares at a spinner, and the data catches up in the background.

The hard part is deciding what to cache. Caching the full catalogue on every device sounds safe and usually isn't: it's slow to download, expensive to keep current, and most of it is never touched by one worker. RapidStock caches the things a worker actually uses:

  • the stores they can sign in to;
  • their product lists and the products on them, with images;
  • their own session, so they can get back in without a connection.

Product lists are the key design choice. A worker or their manager builds a list of the products that team really uses, and the phone keeps that list. It's small enough to sync quickly and specific enough that nearly every scan on the floor hits it.

Writes: queue intents, not totals

This is where most offline apps go wrong. When a worker records that they used three boxes of fixings on a job, there are two ways to store it.

You can update a local total: stock goes from 40 to 37, and you send "stock is now 37" when signal returns. Or you can store the intent: "this person used three of this product on this job at 10:42", and send that event.

For usage, always store the intent. A total is a conclusion drawn from what one device knew at one moment. If two workers each take three boxes while both are offline, both phones will say 37 and the server will believe them. The real number is 34. Usage events add up correctly; totals overwrite each other.

Stocktake counts are different. A count is an observation of what's on the shelf, not a change to it, so two counts of the same product can't simply be added together. They need their own rule, such as accepting the latest count within a stocktake and flagging large differences for review.

In a stock app, the queue usually holds things like recorded usage, order requests and stocktake counts. Each entry should carry the user who did it, what they did, the job or stocktake it belongs to, any note or photo, and the time it actually happened.

The time is easy to overlook. The server needs to know when the work was done, not when the phone found a signal. A usage record created at 10:42 and synced at 15:10 belongs to the morning's job, not the afternoon's.

// A queued action. Illustrative, not RapidStock's code.
class OutboxEntry {
  OutboxEntry({
    required this.id,          // UUID, created on the device
    required this.type,        // 'record_usage', 'stocktake_count', ...
    required this.userId,      // who did it, not who is signed in now
    required this.occurredAt,  // when it happened, in UTC
    required this.payload,
    this.schemaVersion = 1,
    this.attempts = 0,
  });

  final String id;
  final String type;
  final int userId;
  final DateTime occurredAt;
  final Map<String, dynamic> payload;
  final int schemaVersion;
  int attempts;
}

Two fields in that sketch aren't obvious. The id is created on the device, so the server can recognise the same action if it arrives twice; more on that below. The schemaVersion exists because your app will be updated while some phones still have old entries in the queue. When the shape of an entry changes, the new version of the app needs to know how to read the old one.

Who did it? Shared devices change the design

Many offline guides assume one phone, one person. Warehouses don't work like that. A scanner sits on a charging rack and gets picked up by whoever starts the next shift. A team lead signs in on a worker's phone to approve something.

That breaks a simple design where queued actions are sent with whatever session is active. If Priya records usage offline, signs out, and Sam signs in before the phone finds signal, the queue must not send Priya's work as Sam's.

The fix is to store the user who created each queued entry, and to send each person's entries under their own identity rather than whoever is signed in. If that person's session can't be renewed, their entries wait in the queue until they sign in again.

Be careful with security here. If your design keeps credentials for more than one user on a device, they belong in the platform's secure storage (Keychain on iOS, Keystore-backed storage on Android), never in plain preferences, with a lifetime long enough to survive a weekend without signal and short enough that a lost phone isn't a long-term key.

Sync: when it runs and how it behaves

The queue is useless until something drains it. There are three questions: when does sync start, what order does it send things in, and when is an entry safe to delete?

When it starts. Start a sync when the device reports that a connection is back, and again when the main screen opens. Make sure only one sync can run at a time, because an unstable connection can fire several "online" events in a few seconds.

Background sync with the app closed is a separate decision, and the two platforms behave differently.

WorkManager is built for this. Google describes it as the tool for work that must run reliably "even if the user navigates off a screen, the app exits, or the device restarts". You can add a constraint so the job only runs with a network connection, and the work is stored and rescheduled across reboots.

In Flutter, a plugin wraps WorkManager. Keep the background job small: open the queue, send what you can, and stop.

For field apps, foreground sync plus a clear "waiting to send" indicator covers most real use. Workers open the app many times a shift. What they need is confidence that their work is waiting, not a guarantee it left at a particular second.

What order it sends things in. Group related entries and send them together: one person's usage as a batch, an order with all its lines, a stocktake's counts as one submission. The server then receives complete units of work instead of loose lines it has to reassemble. On the server side, each group should be applied or rejected as a whole, so a retry never finds half an order already saved.

When to delete an entry. Only after the server confirms it. Remove a group from the local queue when the server returns success for that group. A failed group stays where it is and goes out on the next attempt.

A connection type isn't a connection

Every Flutter developer reaches for connectivity_plus to detect network state. It's the right tool, but read its caveat. The package documentation says plainly that a connection type "does not guarantee that there is an Internet access". Its example is hotel Wi-Fi with a captive portal, and the same applies to a warehouse access point with a broken uplink.

So check twice. When the platform reports a network, make a real request before treating the app as online, and only then start a sync. Even then, a connection can drop mid-request, so every send still has to handle timeouts and failures and leave the entry queued if it doesn't succeed.

Handle a third state too: the network is fine but the server isn't accepting work, for example during planned maintenance. Treat it like being offline, so workers keep scanning and queueing instead of seeing errors.

The worker shouldn't care why the app can't send. They should keep working, see how much is waiting, and trust that it will go.

PRODUCT

What breaks, and how to design for it

Offline-first brings its own failure modes. These are the failure modes we design for on every field app, whatever the stack.

Offline failure modes and the design that answers each one

Failure
What the user sees
Design answer
Request reaches the server, reply is lost
The action is sent twice and counted twice
A device-generated ID on every entry; the server ignores an ID it has already applied
Two devices change the same stock offline
Counts overwrite each other
Send intents, not totals; let the server add them up
Sync fails silently
Work "disappears" days later
Show a pending count; log every failed group to error monitoring
App update changes the queue format
Old entries can't be read
Version every entry and keep readers for old versions
Refresh token expires during a long offline spell
Queued work can't be sent
Long enough token life for the real gap, plus a prompt to sign in that keeps the queue
Device clock is wrong
Work lands on the wrong day
Store device time and server receipt time; flag large gaps
Phone is lost with unsent work
Work is lost
Sync whenever possible and show what's still pending, so the gap is small and visible; work on a lost phone is still at risk

The first row catches teams out most often. A request can succeed on the server while the response never reaches the phone. The phone sees a failure, keeps the entry and sends it again. Without protection, the server applies it twice.

The standard fix is an idempotency key. Payment APIs have worked this way for years. Stripe's documentation describes the pattern clearly: the client generates a unique key, the server saves the result of the first request with that key, and any retry with the same key gets the same result instead of a second charge. Stripe suggests a random UUID and keeps keys for at least 24 hours.

For an offline queue, the entry's own ID is the key, and it has to be kept for as long as an entry might realistically sit on a device. That's days, not hours, for a phone left in a site van.

Silent failure is the other common problem. A sync loop that catches an error and moves on keeps the app running, which is good. But if nothing records the failure, nobody knows that a device has been holding a stocktake for a week. Report failed sync groups to the same error monitoring as the rest of the app, with a count the worker can see on screen.

Choosing the local storage

Hive, a fast key-value store for Dart with no native dependencies, was a common choice for this in 2025, with the platform's secure storage for tokens. If you're starting today, two options stand out.

Local storage for a new offline-first Flutter app

Option
Best for
Watch out for
hive_ce
Key-value caches and simple queues; teams already on Hive
Basic queries only. It's the community continuation of Hive v2 (2.20.1 in September 2026), so the API will feel familiar to Hive users
drift
Relational data, joins, reporting on the device
More setup and code generation; schema migrations need discipline
Secure storage
Tokens and secrets only
Not for bulk data

Our rule of thumb: if you'd struggle to describe the data without a join, use drift on SQLite. If the data is lists of things keyed by an ID, a key-value store is simpler and quicker to build. Either way, keep secrets out of it.

What we'd tell a team starting one now

These are the lessons we'd pass on to any team building a field app. Most of them are decisions, not libraries.

  1. Write the offline map first. One row per feature, agreed with the client, shown in the app.
  2. Queue intents with the user and the time attached. Never queue totals.
  3. Give every entry a device-generated ID and make the server idempotent on it. Treat duplicates as certain, not possible.
  4. Group entries into units the server understands. One order, one stocktake, one batch of usage.
  5. Check for real connectivity, not a connection type. Handle "server unavailable" as its own state.
  6. Make pending work visible. A small count of items waiting to send builds more trust than any spinner.
  7. Test offline as hard as online. We script "no signal" runs into the test plan from the first sprint now: airplane mode mid-request, signal dropping during a batch, a user switch with work still queued.

Is offline-first worth the cost?

It isn't free. A queue, a sync engine, idempotent endpoints and an offline test plan add real time to a build, and every new feature needs its row on the map. For an app used mostly at a desk, you probably don't need it.

For an app used on a warehouse floor or a job site, our view is firm: build it in from the start. Adding offline writes to an app designed around live requests means reworking every screen that saves data. Designing for it on day one avoids that rework, and the people using the app keep working when the signal drops.

FAQ


For reading cached data and recording work, yes. Some actions will always need a server, such as signing in a brand-new user, searching a catalogue too large for the device, or anything that takes payment. List them openly rather than hiding them.

If you're planning a field or warehouse app and want a second opinion on the offline design, talk to our Flutter team. You can also read how we built the app this post draws on.