Shelter Wi-Fi is a hostile network
Staff enter animals standing in a kennel corridor with one bar of signal and a dog on a lead. Offline-first was not a nice-to-have; it was the only design that worked at all.
The first version of the intake form was an ordinary online form. It worked perfectly in the office and failed roughly one time in three at Kőbányai Állatmenhely, where the kennel block is concrete, the Wi-Fi access point is in a different building, and mobile signal drops to nothing about eight metres in.
What made it worse than a plain error was the shape of the failure: staff filled in a two-minute form, hit save, walked out of the dead zone, and found the form had cleared. After the second time, they stopped using the app in the kennels and went back to paper, which is a completely rational response.
The rule we settled on
Nothing a user typed may exist only in memory, and no screen may block on the network to show what the user just did.
Every write goes to a local SQLite database first and is rendered from there. A background queue drains to the server when there is a connection. The UI never waits on the network to reflect an action, and never shows a spinner for something already saved locally.
async function saveIntake(draft: IntakeDraft) {
const id = draft.id ?? ulid(); // client-generated, stable
await db.write(tx =>
tx.upsert('intake', { ...draft, id, dirty: 1, updated_at: now() })
);
queue.enqueue({ kind: 'intake.sync', id }); // drains when online
return id; // UI proceeds immediately
}Client-generated ULIDs matter more than they look. They mean a record has a real identity before the server has ever heard of it, so photos, notes, and edits can all reference it while offline, and the sync is an upsert rather than a create-then-rewrite-every-reference.
Conflicts
Two staff editing the same animal on two phones in the same building, both offline, is not a hypothetical — it happens at every partner with more than four employees. We resolve per field, not per record, with last-write-wins on a client timestamp, and one exception that is not automatic.
- Scalar fields (name, age, weight, flags): last write wins per field. Losing an edit to a weight field is recoverable and nobody has complained.
- Status transitions (available → reserved → adopted): never auto-merged. A conflict here surfaces a prompt naming both edits, both authors, and both times.
- Photos: additive only. Deletions sync, but two devices adding photos offline both keep theirs.
Clock skew across cheap Android devices was worse than expected — up to 90 seconds — so timestamps are corrected by an offset the client learns from server response headers rather than trusted raw.
What it cost, and what it bought
Roughly six weeks of work, a permanent increase in complexity, and a class of bug that is genuinely hard to reproduce. In exchange: intake completion in the kennel block went from 68% to 99.4%, and paper intake at our three largest partners stopped entirely within a month.
If you are building anything used by people standing up, in a building, doing physical work — assume the network is not there. It usually is not.