İçeriğe geç
wedevit

October 8, 2026 · 10 min read · software

İlhan Buğra Aslan

No signal in the field and the app stops: how to build software that works offline


The hard part of building an app that works offline is not keeping data on the device, it is merging that data back afterwards. A design that holds up rests on five decisions: the screen reads from a local database rather than the network, every write lands in an outbox queue on the device instead of going straight to a service, record ids are minted by the client and not by the server, conflict rules are written per field rather than per record, and each device downloads the slice of data its user actually needs instead of the whole database. "Offline support" bolted on without those decisions demos fine and loses data in week three. Here is where each one breaks in practice.

First separate reading offline from writing offline

The cost gap between these two requirements is large. If a technician in the field only needs to see the work order, the customer address and past service history, you are building a cache: data is downloaded ahead of time, kept on the device, and the screen reads it. No queue, no merge, no conflicts.

If the same technician has to close the work order offline, record parts used and collect a signature, you are building something else. Two devices can now pull the same record in different directions, and that needs a rule.

The question to ask during requirements is simple: which screens are read-only offline, and which ones accept input offline? In most projects the second list turns out to be three screens. Building those three correctly costs far less than making everything writable offline, and it breaks less. The distinction belongs in the requirements document, because much of the spread between vendor quotes hides right there.

The UI reads the local database, not the network

That one sentence is the whole of offline-first architecture. The interface never calls a service directly. It queries a database on the device. Sync is a separate background job that updates that database, and the interface reacts to the change.

Inverting it this way buys two things. The code has one path whether the network is up or down, so the "if online do this, else do that" fork disappears. And the app gets faster even on a good connection, because list screens open from a local query.

In practice the device database is SQLite: Room on Android, Core Data or SwiftData on iOS, SQLite packages in React Native and Expo, Drift in Flutter. The browser equivalent is IndexedDB, which comes with problems of its own.

Offline in a browser is more fragile than offline in an app

A PWA is a reasonable answer for plenty of work, but in the "this device will be disconnected for days" scenario you hit two hard browser limits.

The first is how long the data survives. Under the rule WebKit announced in 2020, Safari deletes all of a site's script-writable storage after seven days of Safari use without user interaction on that site: IndexedDB, LocalStorage, SessionStorage, media keys, Service Worker registrations and cache. Data collected in the field on Friday can be gone if nobody opens the app for two weeks. The same announcement states the exception: web apps added to the home screen are not part of Safari and keep their own counter of days of use. The practical conclusion is that a field app left sitting in a browser tab is not safe storage, and getting users to install it is part of the requirement rather than a nice-to-have.

The second is background sync. The Background Sync API ships in Chrome, Edge, Opera and Samsung Internet. Firefox and Safari do not support it, and neither does any browser on iOS. Libraries like Workbox fall back to replaying queued requests whenever the service worker starts up, which is useful but not the same thing: it needs the app to be opened again.

That makes the decision fairly clear. For a few hours of disconnection, an installed PWA does the job. For days of disconnection with a guaranteed handoff in the background, you move to a native app. The rest of that trade-off is in native versus cross-platform.

Let the device mint the id, not the server

A record created offline cannot ask the server for its id. With auto-incrementing integer keys, two devices both create row 47 in their local database, and sync turns that into a collision.

The answer is to generate the id on the client. Random UUIDs (v4) work. The time-ordered UUID standardised in RFC 9562 as version 7 gives the same uniqueness while behaving better in server-side indexes, because the values grow roughly in chronological order.

There is a second benefit that has nothing to do with uniqueness. A client-minted id doubles as an idempotency key: if the record was delivered but the response was lost, the retry carries the same key and the server does not create a duplicate. Why delivery over a network is "at least once" at best, and how idempotent handling is built, is covered in the data synchronisation post.

The third benefit is the one that matters most day to day. A record that already has an id can be referenced before it ever reaches the server. If a technician creates a new customer offline and immediately files a work order against it, the id that work order points to already exists.

Writes go into a queue, and the screen does not wait for it

The structure that carries offline writes is an outbox. When the user hits save, two things happen inside the same local transaction: the data is written to the local table, and the change is appended as a row to the queue table. The screen shows the new state right away and the user moves on.

The queue row carries few fields: change id, entity, operation (create, update, delete), payload, created time, attempt count and status. Three rules keep that table honest.

Changes to the same record are sent in the order they were made. If the technician opened the work order and then closed it, the server has to see that sequence, otherwise a closed record reopens itself.

Failed attempts retry with growing backoff and eventually stop. A queue that retries forever will carry one malformed record, drain the battery and flood the logs.

The queue lives in durable storage. An in-memory array disappears when the operating system kills the app, and the user goes on believing the data was entered. The server-side version of the same durability argument is in background jobs and queues.

Choose the conflict rule per field, not per record

The default behaviour is usually last write wins. Applied at record level, that rule deletes data quietly. The warehouse lead fixes a quantity while offline; someone in the office updates the note on the same record in the same minute. If both updates carry the whole record, whichever lands second erases the other person's edit and nobody notices.

The practical fix is to push the rule down to the field. Each field carries its own change stamp and merging happens field by field, so the warehouse keeps the quantity edit and the office keeps the note.

Then decide per field type. Last write wins is right for status, selections and settings. It is wrong for counters: if two devices each subtracted 3 from stock, the result is minus 6, not minus 3. Counters should send the delta, or the figure should be recomputed server side. For free text and notes, appending usually beats overwriting; keep both and let a human decide which one stands.

Some conflicts are commercial questions, not technical ones. If two technicians each closed the same job in their own name, no merge algorithm knows the right answer. Those records need a screen for a person: a list of conflicts, the two versions side by side, who wrote what and when. Teams that skip that screen let the system pick a side silently, and six months later nobody trusts the numbers.

One warning about clocks. Phone clocks drift and users set them by hand. A device running five minutes fast overwrites everyone else for those five minutes. A hybrid logical clock, which pairs wall-clock time with a counter that never moves backwards, removes that failure mode, and most modern sync libraries already use one internally. If you need genuine concurrent editing of text, the conversation moves to CRDT libraries such as Automerge, Yjs or Loro, which the majority of field apps never need.

Do not download the entire database to the device

An app that pulls everything on first sync leaves a field user staring at a progress bar for ten minutes on a weak connection. Narrow the scope up front: jobs assigned to this user, customers in their territory, the last ninety days.

Off-the-shelf tools turn this into a configuration file. In PowerSync, sync rules are written as SQL-like queries in a YAML file; the service splits data into buckets by parameter value (one bucket per user id, for example), and a connecting client downloads only its own buckets into a local SQLite database. Writing your own sync does not change the logic: a rule derived from the data model decides who sees what.

Later syncs run off a change cursor. The client sends the last stamp it saw and the server returns only what changed after it. Two details get missed here. Without tombstones for deleted rows, a device never removes the record and the list in the field stops matching reality. And using a timestamp alone as the cursor skips records that share a millisecond, so pagination has to run on the stamp plus the id.

If you pick a hosted sync service, price in vendor risk. MongoDB announced in September 2024 that Atlas Device SDKs and Atlas Device Sync were deprecated and shut the service down on 30 September 2025. The local database stayed open source; the cloud sync did not. Teams with field apps built on it had a year to migrate. The question to ask before choosing: if this service closes, can we run the sync protocol ourselves?

What you can actually promise about background sync

The business usually asks for sync to happen while the app is closed. What you can promise is bounded by the operating system.

On Android, WorkManager schedules work durably, survives a reboot and can be tied to constraints such as network connectivity, charging or battery level. Against that, Doze mode kicks in when the device is unplugged, stationary and the screen has been off for a while, and it suspends network access and defers pending work. The job runs. It does not run "within five minutes."

On iOS the scheduling sits even more firmly with the system. Background refresh tasks are short and the OS decides when, or whether, to run them based on usage patterns. Rather than trying to squeeze large uploads into one of those windows, hand the transfer to a background URLSession so the system carries it out instead of the app.

That leaves a realistic commitment: data is never lost on the device, it is sent when the app is open and the network returns, and it is also sent in the background when the OS allows. Confirming the queue is empty at the end of a shift is an operational responsibility, which means somebody needs a screen that shows it.

Photos are the heaviest part of sync

In field apps most of the traffic is not text, it is images. Damage assessment, before and after installation, a delivery signature, a meter reading. A twelve megapixel photo runs to several megabytes, and one day of visits reaches a hundred megabytes without trying.

Three rules keep that manageable. Do not embed the image in the record payload; keep the file on the device and let the queue row carry a path to it. Send the record first and upload the file separately, so a half-finished transfer on a weak connection does not block the work order from closing. Downscale at capture time, because both the insurance adjuster and the service desk can work from a two megapixel image.

Resumable uploads, validation and storage rules are their own topic, covered in handling file uploads.

The user has to be able to see the queue

The most common complaint about offline apps is not lost data, it is the suspicion of lost data. A user who cannot tell whether something was sent will enter the same record a second time.

Three indicators settle it: the number of pending records, the time of the last successful sync, and a list of records that failed. The third is the one most often skipped. A record rejected twenty times is no longer a technical problem, it needs a person, and the reason the server rejected it (required field empty, record deleted by someone else, no permission) has to be stated in language the user understands.

Marking sync state with a small indicator on each row of a list screen removes a noticeable share of support calls.

Data sitting on a device is a security decision

Working offline means company data leaves a locked server and travels around in a bag. Three things to look at.

Reduce what you carry. If the device only downloads the slice relevant to that user, a lost phone exposes only that slice. An app that pushes the full customer list to every device puts a data export in every employee's pocket.

Encrypt the storage. Operating system device encryption is the base layer, and sensitive data can add database-level encryption such as SQLCipher. Session tokens belong in the Keychain or the Android Keystore, not in a file.

Decide the offline session length explicitly. If the access token expires in an hour and the user will be without a connection for three days, you have to define up front how long the app keeps working offline and what happens when that window closes. Wiping a lost device remotely is a separate question, and the limits of doing that on personal phones are in BYOD and mobile device management.

Airplane mode is not a test

Most offline bugs show up when the network is bad rather than absent. Your test scenarios should include: a connection that drops halfway through an upload, a link that is very slow but not dead, the app being killed by the OS mid-sync, a device whose clock is five minutes fast, two devices editing the same record at once, and a device that comes back after thirty days offline and syncs in one go.

That last one produces the most surprises, because the accumulated queue is both large and order-dependent. Running these tests needs data at realistic volume, which is the subject of test data management.

Answer these before you start

  1. Which screens are read-only offline and which accept input offline? How many screens are on that second list?
  2. How long can a user stay disconnected: one shift or one week? The answer also settles browser versus native app.
  3. Who mints record ids? If it is an auto-incrementing integer, offline creation is broken before you write a line.
  4. When two people edit the same record, which side wins on which field, and when does a human decide instead?
  5. Which slice of the database lands on the device, and how many megabytes is the first sync?
  6. Where does the user see pending and failed records?

If several of those have no clear answer, or if the field team keeps saying "I entered it but it never arrived," we can map the current sync flow together and show exactly where the data goes missing.


Need help with this topic?

get in touch →← all posts