An offline-first triage intake application for field paramedics, built with React Native, TypeScript, and WatermelonDB. Paramedics can log critical patient data instantly, even with zero connectivity — records are guaranteed to persist locally and sync automatically the moment the network returns.
- Overview
- Architecture
- How the Offline-First Sync Engine Works
- Tech Stack & Key Decisions
- Project Structure
- Setup & Run Instructions
- Testing
- Demo
- Known Limitations & Future Improvements
Field paramedics often work in areas with unstable or nonexistent cellular coverage. This app ensures that submitting a triage record never fails and never loses data, regardless of connectivity, by treating the local device as the source of truth and the network as an eventually-consistent sync target.
Core requirements met:
- Single-screen triage intake form (Patient Name, Condition Description, Priority 1–5, Status)
- High-visibility hazard color-coding for critical priorities (P1/P2 in deep red/orange)
- Form validation before submission
- Immediate local persistence via WatermelonDB (SQLite), regardless of network state
- Automatic background sync queue that drains to the server the moment connectivity returns
- No UI freezing, no user intervention required to sync
- Redux Toolkit for state management, cleanly decoupled from persistence/sync logic
- Handles app foreground/background transitions correctly
The app is split into four layers with strict one-directional dependencies — each layer only knows about the one(s) beneath it:
┌─────────────────────────────────────────────┐
│ ui/ Screens & components │
│ (TriageFormScreen) │
└───────────────────┬─────────────────────────┘
│ dispatches to / reads from
┌───────────────────▼─────────────────────────┐
│ state/ Redux Toolkit store │
│ (triageSlice, store) │
└───────────────────┬─────────────────────────┘
│ calls into
┌───────────────────▼─────────────────────────┐
│ sync/ Sync engine │
│ (mockApi, SyncQueueManager, syncController, │
│ useNetworkSync) │
└───────────────────┬─────────────────────────┘
│ reads/writes
┌───────────────────▼─────────────────────────┐
│ data/ WatermelonDB │
│ (schema, TriageRecord model, database.ts) │
└─────────────────────────────────────────────┘
| Layer | Responsibility | Depends on |
|---|---|---|
data/ |
WatermelonDB schema, model, database instance. Pure persistence — no knowledge of React, Redux, or networking. | Nothing |
sync/ |
Mock API repository, connectivity-aware sync queue, and the hook that ties it to app/network lifecycle events. | data/ |
state/ |
Redux Toolkit slice + store. The only layer the UI is allowed to talk to. | data/, sync/ |
ui/ |
Screens and components. Never touches WatermelonDB or the network directly — only dispatches actions and reads state. | state/ |
This separation means the persistence and sync logic can be fully unit-tested without React, and the UI can be redesigned without touching a single line of sync logic.
When a paramedic taps Submit Triage, the form:
- Validates all fields client-side (non-empty name/condition, priority selected)
- Writes the record directly into WatermelonDB with
syncState: Pending— this happens unconditionally, whether the device is online or offline - Immediately reflects the new record in the UI via Redux
- Only after the local write succeeds does it attempt a sync and that attempt is non-blocking and never surfaces an error to the user if it fails
This ordering is the core of the offline-first guarantee: the local database, not the network, is the source of truth at write time.
Simulates the backend endpointPOST /api/v1/triage with:
- A configurable artificial delay (default 2000ms)
- A configurable random failure rate (
setFailureRate(0–1)), used in tests to prove retry behavior
On each drain cycle:
- Checks real device connectivity first, via
NetInfo.fetch(). If offline, it exits immediately without touching any records they remain safelyPending. - Queries WatermelonDB for every record with
sync_stateofPendingorFailed - Processes them sequentially (not in parallel) to preserve chronological order and avoid overwhelming the mock endpoint
- For each record: marks it
Syncing→ attempts the API call → marks itSyncedon success, orFailedon error (never deletes or corrupts the local record — only the sync bookkeeping field changes) - Guards against overlapping drains with an
isProcessingflag
A React hook, mounted once at the app root, that:
- Subscribes to
NetInfo.addEventListenerthe moment the device regains connectivity, it triggers a drain automatically, with no user action required - Subscribes to
AppStatechanges if the app is brought back to the foreground from the background, it re-checks connectivity and triggers a drain if online (covers the case where connectivity was restored while the app was minimized) - Runs an initial drain attempt on mount, in case there are already-pending records from a previous session
drainQueue() only touches WatermelonDB it has no way to notify Redux on its own. triggerSyncAndRefresh() wraps it: drain the queue, then re-read all records from WatermelonDB and push the fresh state into Redux. This is what the UI actually calls (both on manual submit and from the background listener), ensuring the "X of Y records synced" indicator always reflects reality rather than stale state.
"The moment the device regains connection, the application must automatically stream or batch-upload all pending cached records to the server in the background without freezing the UI or requiring user intervention."
- Automatic:
NetInfoevent listener triggers the drain with zero user interaction. - No UI freezing: all database and network operations are
async, and syncing runs independently of form interaction the paramedic can keep entering new records while a previous batch syncs in the background. - No data loss: every write lands in SQLite before any network attempt is made; a failed or interrupted sync leaves the record queued for the next opportunity, never lost.
| Choice | Reasoning |
|---|---|
| Bare React Native, not Expo | WatermelonDB requires native module linking not supported by Expo Go without ejecting to a dev client. |
| WatermelonDB for persistence | Purpose-built for offline-first apps with reactive queries; battle-tested SQLite layer. |
JSI enabled only on iOS (jsi: Platform.OS === 'ios' in database.ts) |
Android JSI mode requires substantial manual native wiring (NDK, custom Gradle module, MainApplication edits) that's fragile to set up correctly. The async bridge adapter needs zero extra native config via standard autolinking and is functionally identical at this app's record volumes (tens, not millions, of records). |
| Redux Toolkit over Context API | Assignment explicitly allows either; Redux Toolkit gives cleaner action-based decoupling between the sync engine (which needs to push state updates from outside any component) and the UI. |
| Mock API with artificial delay + failure toggle, not a real backend | Per assignment instructions proves the sync queue's resilience without requiring live infrastructure. |
Real NetInfo connectivity check inside the sync queue itself (not just at the UI trigger point) |
Prevents the mock API's simulated success/failure from being decoupled from the device's actual network state without this, toggling airplane mode would have no observable effect, since the mock API doesn't check real connectivity on its own. |
An earlier attempt to disable the New Architecture via newArchEnabled=false in android/gradle.properties was made for WatermelonDB stability. As of React Native 0.82+, this flag is ignored the New Architecture runs by default regardless. WatermelonDB's async bridge adapter (used on Android per the decision above) relies on React Native's backward-compatibility layer for legacy native modules, which continues to function under the New Architecture. No native crashes have been observed as a result; if Fabric/TurboModule-related native errors surface in the future, this is the first place to look.
src/
├── data/ # WatermelonDB layer — pure persistence
│ ├── schema/
│ │ ├── schema.ts # Table definition (triage_records)
│ │ └── migrations.ts # Schema version migrations
│ ├── models/
│ │ └── TriageRecord.ts # WatermelonDB Model class
│ ├── testUtils/
│ │ └── testDatabase.ts # In-memory LokiJS database for tests
│ ├── __tests__/
│ │ └── TriageRecord.test.ts
│ └── database.ts # Database singleton (SQLiteAdapter config)
│
├── sync/ # Offline sync engine
│ ├── mockApi.ts # Simulated POST /api/v1/triage
│ ├── syncQueue.ts # SyncQueueManager — drains pending records
│ ├── syncController.ts # Bridges sync engine output back into Redux
│ ├── useNetworkSync.ts # NetInfo + AppState listener hook
│ └── __tests__/
│ └── syncQueue.test.ts
│
├── state/ # Redux Toolkit
│ ├── triageSlice.ts # records[], isSyncing
│ ├── store.ts
│ └── __tests__/
│ └── triageSlice.test.ts
│
├── ui/
│ └── screens/
│ └── TriageFormScreen.tsx # The single-screen intake form
│
└── types/
└── triage.ts # Shared domain types (TriagePriority, TriageStatus, SyncState, DTOs)
- Node.js ≥ 22.11.0
- Java JDK 17+ (or use the JDK bundled with Android Studio, at
Android Studio/jbr) - Android Studio, with the Android SDK, Build Tools, and an emulator or physical device configured
- Environment variables set:
ANDROID_HOMEandJAVA_HOME, both added toPATH(see React Native's official environment setup guide for OS-specific steps)
npm install
# Terminal 1 — Metro bundler
npx react-native start
# Terminal 2 — build and install on a connected device or running emulator
npx react-native run-androidFor iOS (macOS only):
cd ios && RCT_NEW_ARCH_ENABLED=0 pod install && cd ..
npx react-native run-iosOn an emulator, toggle airplane mode via ADB (more reliable than the UI swipe gesture in an emulator window):
# ON
adb shell settings put global airplane_mode_on 1
adb shell am broadcast -a android.intent.action.AIRPLANE_MODE --ez state true
# OFF
adb shell settings put global airplane_mode_on 0
adb shell am broadcast -a android.intent.action.AIRPLANE_MODE --ez state falseOn a physical device, use the real airplane mode toggle in Quick Settings.
- Enable airplane mode
- Fill out and submit a triage record — confirm it saves instantly and shows as unsynced
- Disable airplane mode
- Without touching anything else, watch the sync counter update automatically within a couple of seconds
npx tsc --noEmit # type check
npm test -- --ci # unit testsTest coverage includes:
TriageRecord.test.ts— WatermelonDB model creation, field mapping,toDTO()serialization, updatessyncQueue.test.ts— sync queue behavior under success and simulated failure, including recovery on retrytriageSlice.test.ts— Redux reducer behavior (adding records, updating sync state, syncing flag)App.test.tsx— smoke test that the app renders without crashing
The demo shows: submitting a triage record while the emulator is in airplane mode (record saves locally, stays unsynced), then disabling airplane mode and observing the record automatically sync without any further user interaction.