In a demo, every app works. The office Wi-Fi is fast and the test data is clean. Then the app meets the real world: a technician in a basement, a child on a school tablet with patchy Wi-Fi, a courier in a lift. If the app freezes, shows a spinner forever or silently loses work, users stop trusting it.
Local data is the source of truth
In an offline-first app, the interface reads from and writes to a local database. The network becomes a background process that syncs changes when it can. The user never waits for a server to save their work.
await db.jobs.update(id, { status: 'done', photos })
queue.push({ type: 'job.completed', id, at: Date.now() })
// the UI updates now; sync runs when the network allowsThe hard part is conflicts
If two people edit the same record while offline, something has to decide what wins. We agree on these rules with the client during design, not after the first support ticket.
- Append-only data, such as notes, photos and logs, rarely conflicts.
- For simple fields, the latest change usually wins, with a history kept.
- For money, stock and bookings, the server decides and the app explains.
- Every sync failure is visible to the user with a clear retry option.
Building this way adds some work at the start, mainly in data modelling and sync logic. Retrofitting it into an app that assumed constant connectivity usually means rewriting most of the data layer.
