Offline-first: a clinical app that works with zero signal.
Home visits, rural clinics, basements with no bars. For a clinician in the field, the network is a luxury, not a guarantee. We built a React Native app for a US behavioral-health provider that lets them authenticate, document care and sign consent entirely offline, keeps every keystroke encrypted on the device, and syncs it back, intact and in order, the moment a signal returns.
Care doesn't wait for a signal
The hard part of an offline app isn’t the UI. It’s everything you take for granted when there’s a server in reach: who is this user, where does the data live, is it safe, and how do days of edits reconcile without corrupting a patient’s record.
The client, a US-based IT partner to health services providers (now part of Netsmart), needed a documentation app that simply assumed there was no network and treated connectivity as a bonus. Below is how we answered each of those questions, and the two open-source libraries that came out of it.
An offline-first app, not an online one that tolerates drops
The distinction matters. We weren't hardening an online app against flaky Wi-Fi; we were building one where offline is the default path and the server is optional.
- Authenticate and capture offline. An initial login that works with the device fully disconnected was non-negotiable.
- Keep working through poor or no connectivity, with zero data loss while disconnected.
- Sync on reconnect. Captured documents synchronise to the server the moment connectivity returns.
- Search, filter and sort patient data in both online and offline modes.
- Create progress notes and sign consent forms on-device, regardless of signal.
Four problems that only exist offline
Each one is something an online app simply delegates to the server. Offline, there's no server to delegate to.
Offline authentication
With no server to verify against, the device itself must prove an authorized user, without caching a token an attacker could lift off a rooted device.
On-device storage
Captured data needs somewhere durable to live, with a state-persistence model that lets the app cold-start from exactly where the user left off.
Encryption at rest
HIPAA requires patient data in secondary memory to be encrypted, ideally with a unique key per user, and no secret sitting on disk.
Ordered sync
Queued mutations must replay in causal order. Lose the ordering and you risk silently corrupting a record, the worst possible failure mode for clinical data.
Authenticating with no server to ask
The instant a user can open the app offline, the server is out of the loop, so something on the device has to establish that they’re authorized. The naive answer, caching a token or a key, is exactly what has bitten Android historically: on a booted or rooted device with the right tools, anything written to storage can be read back, key included.
So we decided to store no key. We made an App PIN part of authentication, and, as the next section shows, that same PIN doubles as the encryption key. Nothing persisted to the device reveals it.
Persisting the whole app state to SQLite
Offline-first means the app's state must survive a cold start from secondary storage, and restart the user from exactly where they were.
We reached for redux-persist with SQLite as the backing store, and hit a compatibility gap between redux-persist and react-native-sqlite-storage. Rather than wedge around it, we wrote the bridge ourselves and open-sourced it.
1// SQLite as the backing store for the whole Redux tree.2const storage = SQLiteStorage(SQLite, {3 name: 'clinical-state.db',4 location: 'default',5});6 7const persistConfig = {8 key: 'root',9 storage, // ← our redux-persist ↔ SQLite bridge10 transforms: [encryptState()], // every value encrypted before it hits disk11};12 13const store = createStore(14 persistReducer(persistConfig, rootReducer),15 applyMiddleware(offline(offlineConfig)),16);redux-persist-sqlite-storagea drop-in SQLite storage engine for redux-persist, in production use ever since.The PIN is the key
We needed encryption to do two jobs at once: protect the data at rest, and prove who's unlocking it, all without ever storing a secret.
We never persist the PIN. Instead the PIN derives the encryption key. We keep a single static, known key-value pair (a sentinel) encrypted in storage. On unlock, we decrypt the sentinel with the user-supplied key. If it comes back as the value we expect, the PIN was correct: the user is authenticated and we now hold the key to decrypt the rest of the state. One operation, two guarantees.
1// The PIN is never stored, anywhere. It derives the key, and nothing else.2const deriveKey = (pin: string, salt: string) =>3 pbkdf2Sync(pin, salt, 100_000, 32, 'sha256');4 5// One static sentinel proves the PIN: only the correct key decrypts it back to6// the value we expect, which authenticates the user AND unlocks the store.7async function unlock(pin: string): Promise<SessionKey | null> {8 const key = deriveKey(pin, await getUserSalt());9 const decoded = await tryDecrypt(await readSentinel(), key);10 return decoded === SENTINEL ? key : null; // wrong PIN ⇒ garbage ⇒ null11}12 13// That same session key transparently encrypts the persisted state.14const encryptState = () =>15 createTransform(16 (state) => aes256.encrypt(JSON.stringify(state), sessionKey),17 (blob) => JSON.parse(aes256.decrypt(blob, sessionKey)),18 );Syncing in order, without corruption
We used redux-offline to keep the store consistent offline and replay queued mutations on reconnect. But its outbox is a plain FIFO. It has no smart queue for duplicate resource references or the merging and re-ordering that go with them. Replayed naively, dependent operations can land out of order and corrupt a record.
1// redux-offline ships a FIFO outbox. It can't tell that three edits hit the2// same note, or that a `create` must land before its `update`. Replayed3// naively, that corrupts the record. Our smart queue dedupes by resource and4// preserves causal order.5function enqueue(outbox: Mutation[], next: Mutation): Mutation[] {6 const i = outbox.findIndex((m) => m.resourceId === next.resourceId);7 8 if (i !== -1 && mergeable(outbox[i], next)) {9 const merged = merge(outbox[i], next); // one network call, not two10 return outbox.map((m, k) => (k === i ? merged : m));11 }12 13 return [...outbox, next]; // otherwise append, never reorder dependents14}| Scenario | Naive FIFO outbox | Smart queue |
|---|---|---|
| Three edits to one note | Three calls, last-writer-wins races | One merged call |
| Create, then update | Update can 404: create not yet acked | Causal order preserved |
| Duplicate resource refs | Conflicting writes | Deduped by resourceId |
What it's built on
Deliberately boring, battle-tested primitives, with the two gaps we hit filled by libraries we wrote and gave back.
Redux
A single, serialisable source of truth: exactly what you want to persist and replay.
redux-offline
Offline store consistency + a replay outbox, extended with our ordered, deduping smart queue.
redux-persist
State persistence across cold starts, with a transform layer for transparent encryption.
SQLite storage
Durable on-device storage via our open-source redux-persist-sqlite-storage bridge.
Shipped, and two libraries given back
Open-source libraries shipped or co-owned
Of auth and capture works with zero connectivity
Records lost or corrupted on sync
- Became a core contributor to redux-offline with the smart-queue work.
- Released a new open-source library, redux-persist-sqlite-storage.
- Delivered an offline-first app with HIPAA-compliant, per-user encryption, with no key on disk.
Offline isn't an edge case in the field
For a clinician on a home visit, offline is the normal case. Building for it forces you to answer the questions online apps get to skip: identity, storage, encryption, reconciliation. Answer them well and the network becomes a detail. The app just works, and the data shows up safely the moment it can.
Have a workflow that has to keep working when the signal doesn’t? That’s exactly the kind of problem we like.
Talk to engineering