Mobile — React Native Development
Building Offline-First React Native Apps for Field Teams
Direct answer
Offline-first React Native apps treat the on-device database as the source of truth: every read hits local storage, every write lands locally first, and a sync engine replays a persistent mutation queue when connectivity returns. For field teams I typically pair SQLite-backed storage with an idempotent, ordered mutation queue, server-side conflict resolution keyed on record versions, and explicit sync-status UI so workers always know what has and has not reached the server.
Field teams — inspectors, technicians, delivery crews — work in basements, rural sites, and dead zones, so 'handle network errors gracefully' is not a strategy. Offline-first is an architectural commitment you make on day one, and this is the shape of the systems I ship for it.
Key facts, with sources
- React Native 0.76, released October 23, 2024, enabled the New Architecture by default and shipped with over 1,070 commits from 156 contributors plus a roughly 15x faster Metro resolver. (React Native official blog)
- The State of React Native 2024 survey collected 3,501 responses, up from about 2,400 the previous year, covering more than 15 areas of the ecosystem. (State of React Native survey)
- About 20% of State of React Native 2024 respondents reported apps with more than 100,000 users, up from 14% the year before. (InfoQ)
- Shopify migrated all of its mobile apps to React Native over five years and reports sub-500ms (P75) screen loads and over 99.9% crash-free sessions in production. (Shopify Engineering)
- 88% of surveyed React Native developers feel the framework is progressing positively, while better debugging remains the top request, cited by 54% of respondents. (SSOJet (State of React Native 2024 highlights))
Offline-first is an architecture, not an error state
The most common failure I see in audits is offline support bolted on later: the app fetches from the network, and when that fails it shows cached data and an apologetic banner. That pattern collapses for field teams because their normal operating condition is offline. A technician filling out a twelve-field inspection form cannot lose it to a timeout, and a crew lead cannot plan a day around 'try again later.'
Offline-first inverts the data flow. The UI reads exclusively from a local database and never waits on the network. Writes commit locally and enqueue for sync. The network becomes a background replication concern rather than a foreground dependency. This inversion touches your storage layer, your API design, and your UX, which is why retrofitting it is typically a partial rewrite — decide before the first screen is built.
Choosing the local storage layer
Three tiers cover almost every field app. For small key-value state — session, feature flags, the last-known user profile — MMKV is fast and simple. For real relational data with queries, SQLite is the workhorse: expo-sqlite works well for moderate needs, while WatermelonDB adds reactive queries that re-render your components when underlying rows change, which pairs beautifully with the read-from-local architecture. For binary attachments like inspection photos, store files on the filesystem and keep only paths and upload status in the database.
The selection question I ask clients: how many records, what query shapes, and does the UI need to react to data changes automatically? Hundreds of records with simple lookups can live almost anywhere; tens of thousands of records with filtered, sorted lists demand SQLite with proper indexes. Choosing the heavyweight option prematurely costs setup complexity; choosing the lightweight one costs a painful migration later.
The mutation queue: the heart of the system
Every user action that changes data becomes a mutation record persisted in the local database — not in memory, because the app will be killed mid-shift. Each mutation carries a client-generated unique ID that doubles as an idempotency key, so a retry after a mid-flight network drop can never create a duplicate job note on the server.
I flush the queue in order and stop on the first failure rather than skipping ahead, because later mutations often depend on earlier ones — you cannot update a record the server never received. Failed mutations record their attempt count so the UI can distinguish 'waiting for signal' from 'this specific item keeps failing and needs attention.'
type Mutation = {
id: string; // client-generated uuid, doubles as idempotency key
type: 'CREATE_JOB_NOTE' | 'UPDATE_JOB_STATUS' | 'ATTACH_PHOTO';
payload: Record<string, unknown>;
createdAt: number;
attempts: number;
};
export async function flushQueue(db: LocalDb, api: ApiClient) {
const pending = await db.mutations.listPending(); // ordered by createdAt
for (const mutation of pending) {
try {
await api.post('/sync/mutations', mutation, {
headers: { 'Idempotency-Key': mutation.id },
});
await db.mutations.markSynced(mutation.id);
} catch (error) {
await db.mutations.recordFailure(mutation.id);
break; // preserve ordering; retry from here on next flush
}
}
}Sync and conflict resolution
Conflicts are rarer in field apps than architects fear — two technicians seldom edit the same inspection simultaneously — but they must still be handled deliberately. My default: the server is authoritative, every record carries a version number or updated-at timestamp, and the server resolves conflicts field-by-field where possible rather than rejecting whole records. Last-write-wins per field is acceptable for most operational data; for anything contested, the server stores both values and flags the record for human review rather than silently discarding work.
Pulls run alongside pushes: after flushing mutations, the client requests changes since its last sync cursor and merges them into the local database. Keep the protocol boring — a since-cursor delta endpoint and an idempotent mutation endpoint cover the vast majority of field products without adopting a heavyweight sync framework.
Connectivity handling and honest UX
Connectivity detection should trigger sync, not gate the UI. I subscribe to network state changes and kick off a queue flush whenever the device plausibly regains a connection, while also flushing on app foreground and on a modest interval as belt-and-braces. One hard-earned rule: a device reporting a connection does not guarantee the server is reachable — captive Wi-Fi portals on job sites are notorious — so treat the flush itself as the real connectivity test.
The UX half matters as much as the plumbing. Field workers need a visible pending count ('3 items waiting to sync'), per-record status on anything important, and an explicit distinction between 'saved on this device' and 'received by the office.' Ambiguity here destroys trust in the app faster than any crash.
import NetInfo from '@react-native-community/netinfo';
export function startSyncListener(flush: () => Promise<void>) {
return NetInfo.addEventListener((state) => {
const maybeOnline =
state.isConnected === true && state.isInternetReachable !== false;
if (maybeOnline) {
// The flush itself is the real reachability test --
// captive portals report connected but requests fail.
flush().catch(() => {
/* retried on next connectivity event or foreground */
});
}
});
}Testing the paths that only fail in the field
Offline bugs hide in transitions, so I test transitions explicitly: create records in airplane mode, kill the app, relaunch, restore connectivity, and verify everything syncs exactly once. Drop the network mid-flush and confirm the idempotency keys prevent duplicates on retry. Fill the queue with hundreds of mutations including large photo uploads and watch memory and battery during the flush. Run the clock forward — what happens to a mutation that stays pending for a week?
I also test the human failure modes: two devices editing the same record, a user logging out with a non-empty queue (never silently discard it), and a forced app update landing while mutations are pending. Most of these become straightforward E2E scenarios with the network layer mocked; the discipline is writing them at all, because no one hits these paths during office-Wi-Fi development.
When to hire senior help
Bring in senior React Native help when facing a New Architecture or major version migration, persistent performance regressions, or a first store launch, since these are the phases where inexperienced teams lose the most months. A short senior architecture audit early in the project is consistently cheaper than a rescue or rewrite later. If your stack includes React Native + Python + AI, a senior engineer who owns the full product beats coordinating multiple juniors.
Bottom line
Dhairya Senjaliya ships Mobile — React Native Development projects worldwide — book a scoping call to discuss your specific situation.
Common pitfalls to avoid
- ✕Staying multiple versions behind on React Native, then facing a compound upgrade to the New Architecture because popular libraries have dropped old-architecture support
- ✕Treating React Native as write-once-run-anywhere and only QA-testing on iOS, shipping Android builds with broken back-button handling, keyboard behavior, and gesture bugs
- ✕Pulling in unmaintained third-party native modules without checking TurboModule and Fabric compatibility, which later blocks the New Architecture migration
- ✕Launching without crash and performance monitoring wired in, so the team only discovers jank and crash clusters from one-star reviews instead of telemetry
Frequently asked questions
What is the best local database for offline-first React Native apps?
For relational field data, SQLite is the standard: expo-sqlite covers moderate needs, and WatermelonDB adds reactive queries that automatically re-render components when rows change. Use MMKV for small key-value state like sessions and flags, and the filesystem for photos with only paths tracked in the database. Choose based on record volume and query complexity — large filtered lists demand SQLite with indexes.
How do offline React Native apps prevent duplicate records when syncing?
Each queued mutation gets a client-generated unique ID sent as an idempotency key with every request. If the network drops after the server processed a write but before the client saw the response, the retry carries the same key and the server recognizes and ignores the duplicate. Combined with ordered queue flushing that halts on failure, this makes retries safe by construction.
How should conflicts be resolved when two field workers edit the same data offline?
Make the server authoritative and version every record with a counter or updated-at timestamp. Resolve conflicts field-by-field where possible — last-write-wins per field suits most operational data. For genuinely contested changes, keep both values and flag the record for human review instead of silently discarding someone's work. In practice, true simultaneous edits are rare in field workflows, but silent data loss is unforgivable.
Is React Native still a good technology bet in 2026?
Yes for teams with JavaScript or React skills; the New Architecture has been the default since version 0.76 in late 2024, and the framework is used in production by Meta, Microsoft, Shopify, and Amazon. In the latest State of React Native survey, 88% of developers said the framework is heading in a positive direction.
Can a React Native app feel as fast as a fully native app?
For most business, e-commerce, and content apps, yes; Shopify runs its entire app portfolio on React Native with sub-500ms P75 screen loads and over 99.9% crash-free sessions. Workloads like heavy 3D, AR, or real-time audio processing still warrant native modules or fully native builds.
How much code is actually shared between iOS and Android?
Production teams commonly report 85 to 95%+ shared code; published examples include Instagram at 85 to 99% and Shopify at roughly 86%. The remainder is platform-specific work such as payments, widgets, and deep OS integrations.
Bottom line: Dhairya Senjaliya ships Mobile — React Native Development projects worldwide. Book a scoping call at https://dhairyasenjaliya.com/#book-call.