Skip to content
← All posts
Offline-first 8 min read

Field Service Apps That Keep Working With No Signal

Offline is not a degraded mode you bolt on later. It is the data model, the ID strategy and the conflict rules you pick on day one.

A technician drives into a mechanical room three floors below grade, finishes a two-hour job, and taps Complete. The spinner turns. The app has been holding an unsent HTTP request since he walked in. He force-quits, drives back to the truck, reopens the app, and the work order is back to In Progress with none of his readings, none of his photos, and none of his parts.

That is the actual failure. Not "no internet." The failure is that the app treated the network as a precondition for recording something that already happened in the physical world. The job got done. The data was in the technician's hand. The software threw it away because a socket did not open.

We have shipped this shape of problem in two directions: an offline-capable point of sale in Retailer OS that keeps ringing sales through a dead uplink, and field management in BuildAEC where a superintendent logs daily reports and photo markups on a jobsite with one bar. The lesson from both is the same. Offline is not a feature you add in sprint nine. It decides your primary keys.

Decide what the device owns

Start by splitting your entities into three buckets. Everything downstream follows from this.

  • Device-authored, append-only. Time entries, meter readings, photos, signatures, notes, parts consumed, checklist answers. The technician is the source of truth. The server has no opinion. These sync one way and never conflict.
  • Server-authoritative, read-cached. Price books, tax rates, customer records, equipment specs, form templates, entitlements. The device holds a snapshot with a version stamp and never edits it.
  • Genuinely shared, editable both sides. Work order status, assignment, scheduling window, quote totals. This is the small, dangerous set where two people can change the same field. Keep it as small as you can defend.

Most teams get in trouble by dumping everything into bucket three and then trying to write a general-purpose merge engine. You do not need one. In a well-modeled field service app, 85 to 90 percent of what the technician produces is append-only by nature — a reading taken at 2:14 PM is not in competition with anything. Model it that way and your conflict surface collapses to a handful of fields.

Client-generated IDs, or nothing works

If your server assigns primary keys, offline creation is impossible. A technician who adds a second piece of equipment, three parts and a follow-up work order while disconnected has created records that reference each other. Those references need IDs before the network exists.

Generate UUIDv7 on the device. It sorts by time, which makes it usable as a clustered key in Postgres without the page-split cost of UUIDv4. Keep the human-readable number — WO-2024-00841 — as a separate server-assigned column that is null until sync. Never let a display number be a foreign key.

-- what a synced entity actually needs
id            uuid primary key,        -- generated on device
tenant_id     uuid not null,
created_at    timestamptz not null,    -- device clock, kept as-is
server_seen_at timestamptz,            -- when we accepted it
device_id     text not null,
client_seq    bigint not null,         -- monotonic per device
rev           bigint not null,         -- server bump on each write
deleted_at    timestamptz,             -- tombstone, never hard delete
unique (device_id, client_seq)          -- idempotency key

That unique (device_id, client_seq) constraint is the single highest-value line in the schema. It makes every write idempotent. The device can retry the same mutation forty times over a flaky LTE handoff and the database will accept it once. Without it you will ship duplicate time entries, and duplicate time entries mean wrong payroll, which means the field stops trusting the app.

The outbox is the whole architecture

Do not make network calls from your UI. Write to a local store, enqueue a mutation, and let a background worker drain the queue. The UI reads only local state and is therefore always instant, online or off. This is the part teams skip because it feels like extra plumbing on week two, and it is the part they rewrite on month eight.

Concretely, the sequence for any user action:

  1. Write the record to the local database inside a transaction.
  2. In the same transaction, append a mutation row to the outbox table with the entity id, operation, payload and client_seq.
  3. Return to the UI immediately. Render from local state.
  4. A worker drains the outbox in client_seq order, one batch at a time, stopping on the first non-retryable error.
  5. The server responds with the authoritative row plus its rev. Apply it locally, delete the outbox row.
  6. Pull changes since the last cursor and merge into the local store.

Order matters and per-device ordering is enough. Global ordering across devices is a distributed systems problem you do not have. Two technicians on two work orders do not need serialized writes.

Photos and attachments go on a separate track

A daily report with twelve photos is 40 MB. If your outbox blocks on that upload, nothing else syncs for the rest of the shift. Split it: the metadata record syncs immediately with a local file URI and an upload_state of pending. Binaries upload on their own queue, chunked, resumable, and preferably only on unmetered connections unless the user overrides. In BuildAEC we treat a photo as a first-class record whose file arrives later — the markup, the location and the caption are useful in the office before the pixels land.

Conflict rules you write down before you code

For bucket three, pick a rule per field and document it. "Last write wins" is a legitimate choice, but only when you have decided it is correct for that field and you know whose clock you are trusting. Device clocks drift and technicians change timezones. Compare against server receipt time or a per-entity rev, not raw device timestamps.

Our default set:

  • Status transitions: validate against a state machine, not last-write-wins. If the device says In Progress → Complete and the server already moved to Cancelled, the transition is illegal. Reject it and surface it, do not silently overwrite.
  • Numeric counters (parts used, hours): sum the deltas, never set the absolute. Send +3, not quantity = 8.
  • Free text and single-value fields: last write wins on server receipt order, with the loser preserved in a revision history so nothing is truly lost.
  • Assignment and scheduling: server wins, always. Dispatch is a central function. A device should not be able to reassign itself a job offline.
  • Deletes: tombstone with deleted_at. A hard delete that syncs to a device holding unsent children orphans data.

When a mutation is genuinely rejected, it must land somewhere a human sees. A silent drop is worse than an error. We keep a needs-attention queue in the app, with the rejected payload intact and a one-tap way to resubmit or discard. Technicians will forgive a conflict. They will not forgive vanished work.

A silent drop is worse than an error, because the field stops trusting the app and starts keeping a paper copy.

What to preload, and the honest limit

Offline reads fail differently from offline writes. The technician needs the equipment history, the warranty terms, the last three service visits and the customer's gate code — and none of it is in the local cache because your sync scope is "today's assigned jobs."

Scope the pull by route, not by user. When dispatch assigns tomorrow's eight stops, the device pulls the full graph for those eight: customer, site, equipment tree, last N service records, applicable forms, price book slice. Do it on Wi-Fi the night before or at the shop in the morning. Add a manual Sync Now that shows exactly what came down, because technicians want to verify before they drive out of coverage.

Here is the tradeoff, stated plainly: local-first costs roughly 30 to 50 percent more engineering on the write path than a thin online client. You are building a second database, a migration story for it, a queue, a merge layer, and a debugging surface that lives on a device you cannot SSH into. That is real money. If your field staff work in dense metro coverage, in vehicles with reliable hotspots, and jobs are short, a well-built online client with optimistic UI and a retry buffer for the last few minutes of input may be the right call. Basements, rural routes, industrial plants, elevator shafts, agriculture and remote construction sites are where the full investment pays for itself.

There is also a class of work you should not take offline. Payment authorization, credit checks, real-time inventory availability across locations, anything with a legal timestamp requirement. Mark those as online-only in the UI with an explicit reason, and let the technician queue an intent instead — "collect payment when back in coverage" is honest, a fake receipt is not. We take the same position on what does and does not belong offline in retail: sell offline, settle online.

Where the platform choices land

SQLite on the device, full stop. Not IndexedDB wrappers, not a JSON blob in local storage. You need indexes, transactions and joins because your offline app is a database application. We ship this via Capacitor when we need native hardware — camera with EXIF, Bluetooth probes, barcode scanners, background location — and the same SQLite layer underneath either way.

On the server, keep sync endpoints boring: one POST that accepts a batch of mutations idempotently, one GET that returns changes since a cursor. Two endpoints. Resist the temptation to sync through your general REST API, because sync needs different semantics — batching, idempotency, tombstones, cursors — and bolting those onto CRUD routes produces something neither does well. Running edge workers in front of Postgres works well here; the sync endpoints are small, stateless and latency-sensitive, which is exactly what that architecture is good at.

Then test it the way it actually breaks. Airplane mode is the easy case. The hard cases are the captive portal that returns HTTP 200 with a login page, the LTE handoff that stalls a request at 90 percent, the tunnel that gives you 3 seconds of connectivity every 40 seconds, and the device that has been offline for eleven days and comes back with 400 queued mutations after a schema migration shipped. Write integration tests for each. Set a request timeout of 10 to 15 seconds and treat a timeout as offline, not as a failure.

The measure of success is not sync throughput. It is whether the technician stops keeping a paper copy in the truck. As long as that clipboard exists, the software has not earned the field's trust — the same dynamic that keeps a spreadsheet open next to the construction software. If you want to talk through the sync model for a specific route profile, tell us what your crews drive into.

Frequently asked

Do I need a full local database, or is caching API responses enough?

If technicians only read data offline, a cache works. The moment they create or edit records offline, you need a real local database. Offline writes require client-generated IDs, foreign keys between unsent records, a durable mutation queue and transactional consistency. SQLite gives you all four. A response cache gives you none, and you will discover this the first time two unsent records reference each other.

How do I handle a device that has been offline for weeks after a schema change?

Version your sync protocol, not just your database. The device sends its schema version with every batch; the server either upgrades the payload on receipt or returns a clear upgrade-required response. Never reject old mutations silently. Keep at least two prior payload versions supported, and run migrations on the local database on app launch before the outbox worker starts draining.

Should status changes use last-write-wins?

No. Validate status against a state machine on the server. Last-write-wins on a status field lets a stale device move a cancelled job back to in-progress, or reopen something already invoiced. Accept the transition only if it is legal from the current server state, and if it is not, return a rejection the technician sees in a needs-attention queue with their data still intact.

How much extra does offline-first cost to build?

On our work, the write path takes roughly 30 to 50 percent more engineering than a thin online client, because you are building a second database, a sync queue, conflict rules and a debugging path onto a device you cannot log into. It is worth it when crews routinely lose coverage. It is usually not worth it for office-bound or dense-metro users with short jobs.

Can I use an off-the-shelf sync engine instead of building one?

Sometimes. Managed sync layers handle the queue and merge mechanics well for straightforward document-shaped data. They fit poorly when you need per-tenant database isolation, field-level conflict rules tied to business state machines, server-side validation on every mutation, or separate resumable tracks for large binaries. Prototype your three hardest entities against the tool before committing your data model to it.