Available for new projects & full-time roles

Let's Talk
All posts
Offline-First Is Not a Feature. It's an Architecture Decision You Can't Bolt On Later.
2025-09-15·10 min read·hot take

Offline-First Is Not a Feature. It's an Architecture Decision You Can't Bolt On Later.

Developers treat offline support as a feature to add before launch. It's not, offline-first is a foundational architectural decision that shapes every layer of your app.

offline-first
mobile development
software architecture
React Native
local-first
app development

"We'll add offline support before launch" is the engineering equivalent of "we'll add tests after it's working." It reveals a misunderstanding of what offline-first actually is.

Offline support is not a feature. It's an architectural commitment that determines how you store data, how you handle conflicts, how you design your sync layer, and how you structure your UI state. It touches every layer of the application. Adding it after the fact means rebuilding the application.

TL;DR: Offline-first means the local database is the source of truth and the server is a sync target, which inverts the data flow, the UI state machine, and the mutation layer compared to an online-first app. You can't bolt this on before launch because it isn't a feature, it's an architectural decision touching data storage, conflict resolution, and how mutations get sent. If reliable offline use is a real requirement, that conversation has to happen before you write a line of code, because retrofitting it later is close to a full rebuild.


What "Online-First" Architecture Looks Like

I've seen this pattern many times, and I inherited it on OurStoryz:

typescript
// Online-first data fetching: the default pattern
async function getEventGuests(eventId: string): Promise<Guest[]> {
  // Network is the source of truth. No network = no data.
  const response = await fetch(`/api/events/${eventId}/guests`);
  if (!response.ok) throw new Error("Failed to load guests"); // ← offline = hard failure
  return response.json();
}

// Online-first UI state
function GuestList({ eventId }: { eventId: string }) {
  const [guests, setGuests] = useState<Guest[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    getEventGuests(eventId)
      .then(setGuests)
      .catch(e => setError(e.message)) // ← offline shows error state
      .finally(() => setLoading(false));
  }, [eventId]);

  if (loading) return <Spinner />;
  if (error)   return <ErrorMessage message={error} />; // ← user sees this offline
  return <GuestListView guests={guests} />;
}

This architecture is reasonable when connectivity is guaranteed. In an event venue with 300 people on the same Wi-Fi, it fails constantly.

What Offline-First Architecture Looks Like

When we accepted that offline-first was the actual requirement, the entire design changed:

typescript
// Offline-first: local DB is source of truth, server is sync target
async function getEventGuests(eventId: string): Promise<Guest[]> {
  // ALWAYS read from local. Fast. No network required.
  const localGuests = await realmDb.objects<Guest>("Guest")
    .filtered("eventId == $0", eventId);

  // Trigger background sync, but don't block the read
  syncService.requestSync(eventId).catch(console.warn);

  return Array.from(localGuests);
}

// Offline-first UI state: loading is always near-instant (local read)
function GuestList({ eventId }: { eventId: string }) {
  const [guests, setGuests] = useState<Guest[]>([]);
  const [syncState, setSyncState] = useState<"synced" | "pending" | "offline">("pending");

  useEffect(() => {
    getEventGuests(eventId).then(setGuests); // fast, synchronous-ish

    // Subscribe to sync state changes
    const unsub = syncService.onStateChange(setSyncState);
    return unsub;
  }, [eventId]);

  // No error state for "offline": that's a normal operating condition
  // UI shows sync indicator, not an error message
  return (
    <>
      <SyncIndicator state={syncState} />
      <GuestListView guests={guests} />
    </>
  );
}

The guest list always renders. The sync indicator tells the user their connectivity status. Offline is a first-class state, not an error.

The Four Architectural Changes Offline-First Requires

1. Data Layer: Local First

flowchart LR subgraph online["Online-first"] N[Network] --> S[State] --> UI1[UI] end subgraph offline["Offline-first"] L[Local DB] --> UI2[UI] NET[Network] -. "syncs in background" .-> L end

The local database (Realm, WatermelonDB, SQLite) is the primary source of truth. The server is a sync target, not the source of truth.

2. Conflict Resolution Policy

When two devices modify the same record while disconnected and both sync, who wins? You need a policy before you write sync code:

  • Last-write-wins (LWW): simplest, loses concurrent updates
  • Server-authoritative: server always wins, predictable, limits offline functionality
  • CRDT (Conflict-free Replicated Data Types): complex to implement, no conflicts by construction
  • Merge functions: domain-specific logic (e.g., quantities add, statuses use defined precedence)

For OurStoryz, we used server-authoritative for core guest status (checked-in/not) and LWW for profile edits. The choice was based on which conflicts are recoverable vs. destructive.

3. UI State: New Mental Model

Loading states stop making sense when reads are always local and instant. The state machine changes:

flowchart LR subgraph onlinefsm["Online-first state machine"] I1[idle] --> L1[loading] --> SUC[success] L1 --> ERR["error\n(network failure = hard error)"] end subgraph offlinefsm["Offline-first state machine"] LR2[local-read] --> REN["rendered\n(always succeeds)"] SYNC["sync-state: synced · syncing · pending · conflict"] end

Error states shift from "network failed" to "sync conflict detected", a much rarer and more actionable condition.

4. Transport Layer: Queues, Not Requests

Mutations stop being immediate HTTP requests and become queued operations:

typescript
interface QueuedMutation {
  id: string;
  type: "CHECK_IN" | "UPDATE_PROFILE" | "MARK_VIP";
  payload: unknown;
  queuedAt: number;
  attempts: number;
}

async function checkInGuest(guestId: string): Promise<void> {
  // Apply optimistically to local DB immediately
  await realmDb.write(() => {
    const guest = realmDb.objectForPrimaryKey("Guest", guestId);
    if (guest) guest.checkedIn = true;
  });

  // Queue for sync: will retry until acknowledged
  await mutationQueue.enqueue({
    type: "CHECK_IN",
    payload: { guestId, timestamp: Date.now() },
  });
}

The UI updates instantly. The server eventually gets the mutation. If the device goes offline for 30 minutes, it catches up when connectivity returns.

The Refactor Cost Is Not Worth It

If you're building a mobile app for any environment where connectivity is uncertain, field work, events, industrial settings, travel, don't add offline support. Start offline-first.

The delta between an online-first and offline-first architecture is not additive. You're not adding a sync layer. You're inverting the data flow, redesigning the state model, and rebuilding the mutation layer. That's 70% of a new app.

The conversation you need to have with your product team before writing a line of code: "Is reliable offline use a requirement?" If yes, that conversation changes the architecture, the timeline, and the tech choices. Have it first.


If you are at the "we need offline support" conversation right now, before the architecture is locked, that is the right time to bring me in. Retrofitting costs more than doing it right the first time. Get in touch.

Frequently asked

Which conflict resolution strategy is right for my app?

Last-write-wins is simplest and works when concurrent offline edits to the same record are rare and recoverable. Server-authoritative is safer for high-stakes state (check-ins, payments). CRDTs are the right answer when you need true concurrent merges but they add significant implementation complexity. Pick based on what a conflict actually costs.

What's the best local database for offline-first React Native?

Realm and WatermelonDB are the two serious options for React Native. Realm has better out-of-the-box sync if you're using Atlas Device Sync. WatermelonDB is lighter and more flexible for custom sync layers. For pure SQLite control, op-sqlite is worth evaluating. The choice matters less than committing to it early.

Does offline-first apply to web apps too, or just mobile?

The architecture is the same. Local DB as source of truth, background sync, queued mutations, conflict resolution policy. The implementation layers differ (IndexedDB instead of Realm, Service Workers for background sync), but the data flow inversion is identical. Progressive Web Apps with offline-first architecture follow the same principles.

Hiring or have a project?

Let's build something that holds.

Full-stack engineering, system design, and legacy modernization. Available for freelance, contract, and full-time roles.

Keep reading