Designing an Offline-First Mobile Audit Platform
Case study on building an offline-first audit platform in React Native with local-first data storage, background sync, conflict resolution, and reliable media uploads from remote sites.
Tags
Designing an Offline-First Mobile Audit Platform
TL;DR
An offline-first architecture with WatermelonDB and background sync queues let field auditors complete inspections with zero connectivity, then reliably sync all data and photos when back online. We built a configurable form engine that rendered audit checklists from server-driven schemas, stored everything locally first, and used a conflict-aware sync protocol to merge changes from multiple auditors inspecting the same site.
The Challenge
The client operated a compliance auditing business where field inspectors visited industrial sites — factories, warehouses, construction zones — to conduct safety and regulatory audits. These sites frequently had little to no cellular or Wi-Fi connectivity. The existing workflow was paper-based: inspectors filled out printed checklists, took photos on their phones, then returned to the office to manually enter everything into a web application. Data entry lag averaged three to five days. Photos got lost. Handwriting was misread. Reports were delayed.
They wanted a mobile app that worked exactly like a native tool regardless of connectivity. An inspector should be able to open the app at a remote site with zero signal, complete a full audit with photos and notes, and have everything sync automatically when they drive back into coverage. No "you're offline" error screens. No data loss. No manual sync buttons.
The complicating factors: multiple inspectors sometimes audited the same facility simultaneously. Audit forms varied by industry, region, and regulatory body — there were over 200 active form templates. Photos needed to be full resolution for legal compliance. And the backend was Firestore, which had its own sync mechanisms but didn't handle the offline-first pattern as cleanly as we needed for this use case.
The Architecture
Local-First Data Layer
We chose WatermelonDB as the local database. It's built on SQLite under the hood and designed specifically for React Native apps that need to work offline. The key advantage over raw AsyncStorage or even realm was its lazy loading model — it doesn't load all records into memory, which mattered when an auditor had hundreds of completed audits on their device.
// database/schema.ts
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const auditSchema = appSchema({
version: 8,
tables: [
tableSchema({
name: 'audits',
columns: [
{ name: 'server_id', type: 'string', isOptional: true },
{ name: 'facility_id', type: 'string' },
{ name: 'template_id', type: 'string' },
{ name: 'status', type: 'string' }, // draft | in_progress | completed | synced
{ name: 'auditor_id', type: 'string' },
{ name: 'started_at', type: 'number' },
{ name: 'completed_at', type: 'number', isOptional: true },
{ name: 'sync_status', type: 'string' }, // pending | syncing | synced | conflict
{ name: 'version', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'audit_responses',
columns: [
{ name: 'audit_id', type: 'string' },
{ name: 'question_id', type: 'string' },
{ name: 'response_type', type: 'string' },
{ name: 'value', type: 'string' },
{ name: 'notes', type: 'string', isOptional: true },
{ name: 'flagged', type: 'boolean' },
{ name: 'updated_at', type: 'number' },
],
}),
tableSchema({
name: 'media_attachments',
columns: [
{ name: 'audit_response_id', type: 'string' },
{ name: 'local_uri', type: 'string' },
{ name: 'remote_url', type: 'string', isOptional: true },
{ name: 'upload_status', type: 'string' }, // pending | uploading | uploaded | failed
{ name: 'file_size', type: 'number' },
{ name: 'mime_type', type: 'string' },
{ name: 'created_at', type: 'number' },
],
}),
],
});Every interaction wrote to WatermelonDB first. The app never made a network request as part of the user's action flow. When an inspector tapped "Pass" on a checklist item, that response was saved locally in under 10 milliseconds. The UI was always fast because it never waited on the network.
Configurable Form Engine
With 200+ audit templates, hardcoding forms was impossible. We built a form engine that consumed JSON schemas and rendered the appropriate controls. Templates were synced to the device periodically and cached locally, so they were available offline too.
// forms/FormEngine.tsx
import React from 'react';
import { View } from 'react-native';
import { CheckboxField } from './fields/CheckboxField';
import { PhotoField } from './fields/PhotoField';
import { TextInputField } from './fields/TextInputField';
import { RatingField } from './fields/RatingField';
import { SignatureField } from './fields/SignatureField';
interface FormField {
id: string;
type: 'checkbox' | 'text' | 'photo' | 'rating' | 'signature';
label: string;
required: boolean;
options?: Record<string, unknown>;
conditionalOn?: { fieldId: string; value: string };
}
interface FormSection {
id: string;
title: string;
fields: FormField[];
}
const FIELD_COMPONENTS = {
checkbox: CheckboxField,
text: TextInputField,
photo: PhotoField,
rating: RatingField,
signature: SignatureField,
};
export function FormEngine({
sections,
responses,
onResponseChange,
}: {
sections: FormSection[];
responses: Map<string, string>;
onResponseChange: (fieldId: string, value: string) => void;
}) {
const isFieldVisible = (field: FormField): boolean => {
if (!field.conditionalOn) return true;
return responses.get(field.conditionalOn.fieldId) === field.conditionalOn.value;
};
return (
<View>
{sections.map((section) => (
<View key={section.id}>
{section.fields.filter(isFieldVisible).map((field) => {
const Component = FIELD_COMPONENTS[field.type];
return (
<Component
key={field.id}
field={field}
value={responses.get(field.id) ?? ''}
onChange={(value: string) => onResponseChange(field.id, value)}
/>
);
})}
</View>
))}
</View>
);
}The form engine supported conditional fields (show question B only if question A was answered "Fail"), validation rules embedded in the schema, and section-level progress tracking. Inspectors could see completion percentages per section, which helped on large audits with 100+ checklist items.
Sync Engine with Conflict Resolution
The sync engine ran as a background process. It monitored network connectivity using @react-native-community/netinfo and triggered sync cycles when the device came online. Sync was also manually triggerable, but the goal was that inspectors never had to think about it.
// sync/SyncEngine.ts
import NetInfo from '@react-native-community/netinfo';
import { database } from '../database';
import { syncWithFirestore } from './firestoreSync';
import { uploadPendingMedia } from './mediaUploader';
class SyncEngine {
private isSyncing = false;
private syncQueue: string[] = [];
initialize() {
NetInfo.addEventListener((state) => {
if (state.isConnected && !this.isSyncing) {
this.performSync();
}
});
}
async performSync() {
if (this.isSyncing) return;
this.isSyncing = true;
try {
// Step 1: Push local changes to server
const pendingAudits = await database
.get('audits')
.query(Q.where('sync_status', Q.oneOf(['pending', 'conflict'])))
.fetch();
for (const audit of pendingAudits) {
await this.pushAudit(audit);
}
// Step 2: Pull remote changes
await this.pullRemoteChanges();
// Step 3: Upload pending media
await uploadPendingMedia();
} catch (error) {
console.error('Sync failed:', error);
} finally {
this.isSyncing = false;
}
}
private async pushAudit(audit: Audit) {
const serverVersion = await this.getServerVersion(audit.serverId);
if (serverVersion && serverVersion > audit.version) {
// Server has newer data — conflict
await this.handleConflict(audit, serverVersion);
return;
}
await syncWithFirestore(audit);
await audit.update((a: Audit) => {
a.syncStatus = 'synced';
a.version = (a.version || 0) + 1;
});
}
private async handleConflict(localAudit: Audit, serverVersion: number) {
// For non-critical fields, last-write-wins with server timestamp
// For audit findings (pass/fail/flagged), mark for manual review
const serverData = await this.fetchServerAudit(localAudit.serverId);
const conflicts = this.detectFieldConflicts(localAudit, serverData);
if (conflicts.some((c) => c.isCritical)) {
await localAudit.update((a: Audit) => {
a.syncStatus = 'conflict';
});
// Queue notification for auditor to resolve manually
} else {
// Auto-merge non-critical fields using server values
await this.autoMerge(localAudit, serverData);
}
}
}
export const syncEngine = new SyncEngine();The conflict resolution strategy was two-tiered. Metadata fields like auditor notes, timestamps, and status used last-write-wins with the server timestamp as the tiebreaker. Critical fields — the actual audit findings (pass, fail, flagged) — triggered a manual conflict resolution screen. We couldn't auto-merge a safety finding because a wrong resolution could have legal implications.
Media Upload Pipeline
Photos were the heaviest part of the sync. A single audit could have 50+ full-resolution photos, each several megabytes. We built a chunked upload pipeline that ran as a background task using react-native-background-fetch:
// sync/mediaUploader.ts
import RNFS from 'react-native-fs';
import { database } from '../database';
import { storage } from '../firebase';
const MAX_CONCURRENT_UPLOADS = 3;
const MAX_RETRIES = 5;
export async function uploadPendingMedia() {
const pending = await database
.get('media_attachments')
.query(Q.where('upload_status', Q.oneOf(['pending', 'failed'])))
.fetch();
// Process in batches to avoid overwhelming the connection
const batches = chunk(pending, MAX_CONCURRENT_UPLOADS);
for (const batch of batches) {
await Promise.allSettled(
batch.map((attachment) => uploadSingleMedia(attachment))
);
}
}
async function uploadSingleMedia(attachment: MediaAttachment) {
let retries = 0;
while (retries < MAX_RETRIES) {
try {
await attachment.update((a: MediaAttachment) => {
a.uploadStatus = 'uploading';
});
const fileExists = await RNFS.exists(attachment.localUri);
if (!fileExists) {
await attachment.update((a: MediaAttachment) => {
a.uploadStatus = 'failed';
});
return;
}
const ref = storage.ref(`audits/${attachment.auditResponseId}/${Date.now()}`);
await ref.putFile(attachment.localUri);
const remoteUrl = await ref.getDownloadURL();
await attachment.update((a: MediaAttachment) => {
a.remoteUrl = remoteUrl;
a.uploadStatus = 'uploaded';
});
return;
} catch (error) {
retries++;
if (retries >= MAX_RETRIES) {
await attachment.update((a: MediaAttachment) => {
a.uploadStatus = 'failed';
});
}
// Exponential backoff
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, retries)));
}
}
}The uploader ran in batches of three concurrent uploads to avoid saturating limited bandwidth. Exponential backoff on failures prevented hammering the server when connectivity was flaky. Each attachment tracked its own upload status, so partial uploads could resume without re-uploading already-completed files.
Key Decisions & Trade-offs
WatermelonDB over Realm. Realm was the other serious contender. It offered automatic sync with MongoDB Atlas, which would have eliminated our custom sync engine. We chose WatermelonDB because the backend was already Firestore, not MongoDB, and because WatermelonDB's lazy loading performed better with large datasets. Realm loads related records eagerly, which caused noticeable lag when opening audits with hundreds of responses.
Custom sync over Firestore offline persistence. Firestore has built-in offline support, but it's designed for temporary connectivity gaps, not extended offline periods. Its offline cache has size limits, and it doesn't give fine-grained control over conflict resolution. Our auditors could be offline for hours or even a full workday. We needed a local database that worked without any Firestore dependency and a sync layer we fully controlled.
Server-driven forms over hardcoded templates. The form engine added complexity, but the alternative was deploying app updates every time a regulatory body changed an audit checklist. With server-driven schemas, the compliance team could modify templates through an admin portal and inspectors received the changes on their next sync. No app store review cycle. No forced updates.
Manual conflict resolution for critical fields. Auto-merge would have been simpler and wouldn't interrupt the auditor's workflow. But audit findings carry legal weight. If two inspectors recorded different results for the same checklist item, someone had to make a conscious decision about which one was correct. The manual resolution screen showed both values side-by-side with timestamps and auditor names, letting the reviewer make an informed choice.
Full-resolution photos. Compressing photos would have reduced upload times and storage costs. Legal counsel vetoed it — audit photos might be used as evidence in regulatory proceedings, and compression could be argued to have altered the evidence. So we stored originals and generated thumbnails locally for the UI.
Results & Outcomes
The most significant outcome was the elimination of paper-based workflows. Auditors went from a three-to-five-day data entry lag to real-time availability. As soon as they drove back into cell coverage, audit data started appearing on the management dashboard.
Data accuracy improved substantially. Handwriting misreads disappeared. Required fields were enforced by the form engine, so incomplete audits couldn't be submitted. Photo attachments were linked directly to specific checklist items rather than dumped into a shared folder.
Inspector productivity increased because they no longer spent hours after fieldwork doing manual data entry. The time savings let the company handle more audits without hiring additional inspectors.
The offline experience was genuinely seamless. During pilot testing, several inspectors didn't realize the app was working offline — they assumed they had connectivity because everything worked normally. That was the best validation we could have received.
The conflict resolution system was used less frequently than expected. Most conflicts arose from metadata fields (notes, timestamps) that auto-merged cleanly. Critical field conflicts averaged fewer than two per month across the entire inspector team, suggesting that the workflow naturally prevented most concurrent editing situations.
What I'd Do Differently
I'd evaluate CRDTs (Conflict-free Replicated Data Types) for the sync layer. Our version-based conflict detection worked, but CRDTs would have eliminated the need for manual conflict resolution in most cases by making merges mathematically deterministic. Libraries like Yjs or Automerge have matured significantly since we built this.
I'd build a more robust migration system for the local database schema. WatermelonDB supports migrations, but our early migration code was brittle. Schema version 8 meant we had seven migration steps that ran sequentially on app update. Refactoring these into a more maintainable pattern — or using a migration framework that could squash old migrations — would have saved debugging time.
I'd also invest in better sync observability. When sync failures happened, diagnosing them was difficult because the sync engine ran in the background on a user's device. We eventually added structured logging that uploaded sync diagnostics on the next successful connection, but having that from the start would have shortened our debugging cycles.
FAQ
What does offline-first mean for a mobile app?
Offline-first means the app is designed to work fully without an internet connection as the default state. Data is stored locally first and synced to the server when connectivity is available, rather than treating offline as an error condition. In our audit platform, every user action — answering a checklist item, taking a photo, adding a note — wrote directly to WatermelonDB on the device. The network was never in the critical path of any user interaction. The app didn't distinguish between online and offline from the user's perspective; it worked identically in both states. Sync happened transparently in the background. This is fundamentally different from "offline-capable" apps that cache data for temporary disconnections but are designed around the assumption that connectivity is the norm.
How do you handle conflicts when syncing offline data?
We used a last-write-wins strategy with server timestamps for most fields, and a manual conflict resolution UI for critical data like audit findings. Each record carries a version number so the server can detect and flag conflicts during sync. When two auditors edited the same audit offline, the sync engine compared version numbers. If the server version was ahead of the local version, a conflict existed. For non-critical fields like notes and timestamps, the server value won automatically. For audit findings — the pass/fail/flagged determinations that carry legal weight — the app queued a conflict for manual review. The auditor saw both values, who made each change, and when, then chose the correct resolution. This two-tiered approach balanced automation with the need for human judgment on data that mattered.
How are media files handled in offline-first apps?
Photos and documents are saved to local storage immediately and added to a background upload queue. When connectivity returns, a background task uploads files in chunks with retry logic, then updates the local record with the remote URL. In our implementation, the camera saved photos to the device's file system and created a media_attachments record in WatermelonDB with the local URI and a pending upload status. The upload pipeline processed files in batches of three, used exponential backoff on failures, and tracked per-file progress. If the app was killed mid-upload, the next launch detected pending and failed attachments and resumed. We stored full-resolution originals (required for legal compliance) and generated thumbnails locally for the UI, so inspectors could review photos without waiting for uploads. The entire media pipeline was designed around the assumption that uploads would fail frequently and partially — resilience was the default, not an afterthought.
Collaboration
Need help with a project?
Let's Build It
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.
Related Articles
Optimizing Core Web Vitals for e-Commerce
Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.
Building an AI-Powered Interview Feedback System
How we built an AI-powered system that analyzes mock interview recordings and generates structured feedback on communication, technical accuracy, and problem-solving approach using LLMs.
Migrating from Pages to App Router
A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.