Field Notes

Designing Offline-First Apps for Unreliable Networks

Oct '25

(and what I learned comparing SQLite, MMKV, and AsyncStorage)

When you build apps in emerging markets, you stop assuming people have stable internet. You start thinking like this: what happens when the driver goes offline halfway through his route? or when a retailer tries to browse products underground with 2G?

That’s when “offline-first” stops being a buzzword and becomes survival.

The reality check

I’ve worked on a few mobile apps where connectivity is more of a luxury than a guarantee. The pattern is always the same: people want to move fast, even when the network doesn’t.

At Kamioun, retailers browse hundreds of products, create carts, and schedule orders — all while serving customers in their shop. Losing connectivity in the middle of that flow is not an option.

So I had to pick the right local storage strategy to make the app truly offline-first.

AsyncStorage — the friendly but slow baseline

AsyncStorage is great until you need more than “remember last login.” It’s simple key/value, serializes everything as strings, and doesn’t scale well when you store complex objects or thousands of records.

I’ve used it for user settings, tokens, and small UI states — anything light and read occasionally.

But as soon as I tried to store cached products or carts, performance dropped and reads became laggy.

AsyncStorage = quick to use, slow to grow.

import AsyncStorage from '@react-native-async-storage/async-storage'

export const saveToken = async (token: string) => {
  await AsyncStorage.setItem('auth_token', token)
}

export const getToken = async () => {
  return await AsyncStorage.getItem('auth_token')
}

MMKV — the fast but limited cache

Then comes MMKV, Meta’s key-value storage built on C++. It’s blazing fast and works well for anything you need to access frequently — session state, toggles, local flags, or lightweight data snapshots.

I switched some small modules to MMKV and instantly felt the difference: reads were synchronous, and state hydration on app launch was almost instant.

import MMKVStorage from 'react-native-mmkv-storage'
const MMKV = new MMKVStorage.Loader().initialize()

export const setConfig = (key: string, value: any) => {
  MMKV.setMap('config', { ...MMKV.getMap('config'), [key]: value })
}

export const getConfig = (key: string) => MMKV.getMap('config')?.[key]

The trade-off: it’s still key-value. You can’t query data, filter by attributes, or handle large relational structures easily. It’s not a database — it’s a high-speed cache.

SQLite — the real offline backbone

For real offline use, I needed SQLite. It’s structured, queryable, and persistent. I used it to store full product catalogs, order drafts, and even local sync queues.

What I liked most:

  • You can store thousands of records and query instantly.
  • Perfect for “local-first” sync logic — write to SQLite first, then sync in background.
  • You can version your schema and evolve it as the app grows.

SQLite was the only one that made the app feel reliable when the internet wasn’t. Retailers could still browse, add to cart, and plan orders — then everything synced silently once the connection came back.

import * as SQLite from 'expo-sqlite'

const db = SQLite.openDatabase('kamioun.db')

export const initDB = () => {
  db.transaction(tx => {
    tx.executeSql(
      'CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY NOT NULL, data TEXT, synced INT);'
    )
  })
}

export const insertOrder = (data: any) => {
  db.transaction(tx => {
    tx.executeSql('INSERT INTO orders (data, synced) VALUES (?, 0);', [JSON.stringify(data)])
  })
}

export const getPendingOrders = (cb: (orders: any[]) => void) => {
  db.transaction(tx => {
    tx.executeSql('SELECT * FROM orders WHERE synced = 0;', [], (_, { rows }) => cb(rows._array))
  })
}

My final setup

In the end, I used all three:

  • AsyncStorage → app preferences, auth tokens, and onboarding flags
  • MMKV → cached UI state and frequently read configs
  • SQLite → offline business data and queued updates

Each layer has its role — and combining them gave me both speed and reliability without over-engineering.

// simplified pattern using Zustand + React Query + SQLite
import { useQuery } from '@tanstack/react-query'
import { create } from 'zustand'
import { getPendingOrders } from './db'

const useOrdersStore = create(set => ({
  orders: [],
  setOrders: (orders: any[]) => set({ orders })
}))

export const useOrders = () => {
  const { setOrders } = useOrdersStore()
  return useQuery(['orders'], async () => {
    const local = await new Promise<any[]>(resolve => getPendingOrders(resolve))
    setOrders(local)
    return local
  })
}

What I’d do differently next time

If I had to start again, I’d define a clear data lifecycle from day one:

  • What must persist across reinstalls?
  • What can be purged anytime?
  • What needs to sync with backend logic?

Offline-first isn’t a tech choice — it’s a mindset. The goal isn’t to make the app “work offline.” It’s to make it feel like the network doesn’t even matter.

*(Built with Expo, React Native, Zustand, React Query, and a lot of debugging in airplane mode.)*