The Reach app is used in hospitals across 70 countries, including many where mobile connectivity is unreliable. A session between a nurse and an interpreter can start in a well-connected hospital wing and move to a basement ward with no signal. The app can't show a loading screen and wait.
Offline-first is not a feature flag. It's an architecture. Every data access path, every user action, every piece of UI state needs to account for the possibility that the network isn't available. Here's how we built it.
The architecture decision
We settled on three layers:
-
MMKV for fast synchronous reads: key-value storage for user preferences, cached configuration, recent session data. MMKV is synchronous, which means no awaiting in render paths. No loading states for data that should be instant.
-
SQLite for structured offline data: session records, message history, interpreter profiles. Data that needs querying (filter by language, sort by date) lives in SQLite. We use
react-native-quick-sqlitefor synchronous access. -
Sync queue for pending operations: every write operation (send a message, update a session status, submit feedback) goes into a queue stored in SQLite. The queue is processed when connectivity is available.
interface QueuedOperation {
id: string;
type: 'create_session' | 'send_message' | 'update_status';
payload: Record<string, unknown>;
createdAt: number;
retryCount: number;
maxRetries: number;
}
When the user performs an action, it's written to the local database and added to the sync queue simultaneously. The UI updates immediately from the local data. The sync queue processes in the background.
Optimistic updates
Every user action updates the UI immediately, before the server confirms it.
async function sendMessage(sessionId: string, text: string) {
const message = {
id: generateId(),
sessionId,
text,
sender: currentUser.id,
timestamp: Date.now(),
status: 'pending',
};
// Update local database immediately
await localDb.insertMessage(message);
// Queue for server sync
await syncQueue.enqueue({
type: 'send_message',
payload: message,
maxRetries: 5,
});
}
The message appears in the chat immediately with a "pending" indicator. When the server confirms it, the status changes to "sent." If the server rejects it (validation failure, authorization issue), the status changes to "failed" and the user sees an option to retry or delete.
Conflict resolution
When two users edit the same data offline and both sync, their changes conflict. Our strategy depends on the data type:
Messages: last-write-wins. Messages are append-only, so conflicts are rare. If two users send a message at the same time, both are accepted and ordered by timestamp.
Session status: server wins. The session status (active, paused, ended) is authoritative on the server. If a local status change conflicts with a server change, the server's version takes precedence and the local state is updated.
User preferences: client wins. Preferences are per-user and per-device. There's no meaningful server-side version.
The conflict resolution logic runs during sync:
async function resolveConflict(
local: Record<string, unknown>,
server: Record<string, unknown>,
type: string
): Promise<Record<string, unknown>> {
switch (type) {
case 'message':
return local; // Append, no conflict
case 'session_status':
return server; // Server authoritative
case 'preferences':
return local; // Client authoritative
default:
return server; // Default to server
}
}
Background sync
The sync queue processes whenever the network is available. On React Native, we use @react-native-community/netinfo to detect connectivity changes:
import NetInfo from '@react-native-community/netinfo';
let isSyncing = false;
NetInfo.addEventListener((state) => {
if (state.isConnected && !isSyncing) {
processQueue();
}
});
async function processQueue() {
isSyncing = true;
try {
const pending = await syncQueue.getPending();
for (const operation of pending) {
try {
await executeOperation(operation);
await syncQueue.markCompleted(operation.id);
} catch (error) {
if (operation.retryCount >= operation.maxRetries) {
await syncQueue.markFailed(operation.id);
} else {
await syncQueue.incrementRetry(operation.id);
}
}
}
} finally {
isSyncing = false;
}
}
Operations are processed in order. Failed operations are retried with a limit. Operations that exceed the retry limit are marked as failed and surfaced to the user.
Battery is a consideration. We don't sync continuously. Sync runs when:
- The app transitions from offline to online
- The user opens the app (foreground event)
- A specific user action triggers sync (pull-to-refresh, sending a message)
We don't use periodic background sync because it drains battery on devices that may already be at low charge in a hospital setting.
The multilingual challenge
The app supports over 50 languages. Translation files for all languages can't be loaded into memory simultaneously on low-end devices.
We load the current language at startup and cache the two most recently used fallback languages. If the user switches to a language that isn't cached, we load it from SQLite (where all translations are stored offline).
const translationCache = new Map<string, Record<string, string>>();
async function getTranslation(key: string, lang: string): Promise<string> {
if (!translationCache.has(lang)) {
const translations = await localDb.getTranslations(lang);
translationCache.set(lang, translations);
// Evict oldest if cache exceeds 3 languages
if (translationCache.size > 3) {
const oldest = translationCache.keys().next().value;
translationCache.delete(oldest);
}
}
return translationCache.get(lang)?.[key] || key;
}
Network drops mid-session
The specific challenge for a real-time interpreter session: the network drops in the middle of a conversation. The WebSocket connection dies. Messages stop flowing.
Our approach:
- Messages are queued locally immediately, regardless of connection state
- The UI continues to work. The user can type and "send" messages that queue locally
- When the connection is restored, queued messages are sent in order
- The WebSocket reconnection logic has exponential backoff but caps at 5 seconds (not the 30 seconds typical for non-critical applications) because the session is time-sensitive
The user sees a banner: "Connection lost. Messages will be sent when reconnected." This is more informative and less alarming than a generic error screen.
Building offline-first is more work upfront. Every feature takes longer because you're building it twice: once for the online path and once for the offline path. But in environments where connectivity is unreliable, it's the difference between an app that works and an app that doesn't.