# How We Built PrepOrder to Work When the Network Doesn't

**Building a food ordering app for Africa means accepting one hard truth: the network will let your users down. Every single day.**

PrepOrder is a food ordering app built in Kampala. From day one we knew our users would bounce between 2G, patchy 3G, and WiFi that drops mid-request. The usual loading spinner, error, retry loop isn’t just annoying. It’s a dealbreaker when every megabyte costs real money.

So we designed for data resilience from the first commit, not as an afterthought. Here’s how.

## 1\. Classify your data before you cache anything

The biggest mistake in offline-first design is treating all data the same. We split everything into four freshness classes:

```plaintext
Class A — Cacheable (hours/days)
  Restaurant profiles, menus, categories, reviews, user profile

Class B — Fresh-ish (minutes)
  Today feed, offers, discovery results

Class C — Server-authoritative (seconds)
  Availability, active orders, pricing at checkout

Class D — Local-only
  UI preferences, theme, selected filters
```

This classification drives every caching decision that follows. A restaurant description can sit in cache for six hours. An offer’s remaining count needs to be fresh within minutes. Cart pricing at checkout? Never trust the cache. Always check with the server.

## 2\. Stale-while-revalidate, per data type

We use TanStack Query as the server-state layer. Every query gets a `staleTime` that matches its freshness class:

```typescript
const staleTimeMs = {
  restaurantProfile: 6 * 60 * 60_000, // 6 hours
  menu: 30 * 60_000, // 30 minutes
  todayFeed: 60_000, // 1 minute
  offers: 60_000, // 1 minute
  availability: 15_000, // 15 seconds
  activeOrder: 30_000, // 30 seconds
} as const;
```

The pattern is the same everywhere: show cached data instantly, then refetch in the background. The user never sees a spinner for data we’ve already downloaded. When the fresh response arrives, the UI updates without drama.

This is stale-while-revalidate. The important part is that each data type gets its own clock. A restaurant profile that’s two hours old is fine. A menu item’s availability that’s two hours old could be wrong.

## 3\. Persist the entire query cache to disk

TanStack Query caches in memory by default, so everything vanishes on app restart. We fix that with AsyncStorage-backed persistence:

```typescript
const asyncStoragePersister = createAsyncStoragePersister({
  storage: AsyncStorage,
});

<PersistQueryClientProvider
  client={queryClient}
  persistOptions={{
    persister: asyncStoragePersister,
    maxAge: 7 * 24 * 60 * 60_000, // 7 days
  }}
>
```

Now when a user opens the app with no internet, they see their last-known Today feed, restaurant profiles, and menus, all served from disk. The app doesn’t know or care that it’s offline. It just renders what it has.

## 4\. Network detection that actually works

We use `@react-native-community/netinfo` synced to TanStack’s `onlineManager` at the root layout:

```typescript
function useConnectivitySync() {
  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state) => {
      const reachable =
        state.isConnected === true &&
        state.isInternetReachable !== false;
      onlineManager.setOnline(reachable);
    });
    return unsubscribe;
  }, []);
}
```

The critical detail is that we check `isInternetReachable !== false`, not just `isConnected`. On Android, WiFi can report `isConnected: true` while having no actual internet. That distinction drives retry logic, UI banners, and query behavior across the whole app.

When the network drops:

*   Retries stop immediately (no battery wasted)
    
*   Cached data keeps serving
    
*   An OfflineBanner shows: “You’re offline · Showing saved information”
    

When it returns:

*   `refetchOnReconnect: true` triggers automatic background refresh
    
*   The banner flips to “Back online” for 2.5 seconds, then fades
    

## 5\. ETag conditional requests save real bytes

Every GET request includes an `If-None-Match` header if we’ve seen the response before:

```typescript
const etagCache = new Map<string, { etag: string; body: unknown }>();

// Send conditional header
const cached = etagCache.get(cacheKey(url));
if (method === "GET" && cached) {
  headers["If-None-Match"] = cached.etag;
}

// 304 = nothing changed, replay cached body
if (response.status === 304 && cached) {
  return cached.body as T;
}
```

If the server responds with `304 Not Modified`, we skip the entire response body. On a 2G connection where a restaurant profile might be 8KB, this saves real money over hundreds of requests per session.

## 6\. Image variants — never download more than you need

Every image in the API comes in variants:

```typescript
{
  thumb: "/media/dishes/abc_thumb.webp", // ~5KB — list cards
  card: "/media/dishes/abc_card.webp", // ~15KB — detail cards
  hero: "/media/dishes/abc_hero.webp", // ~40KB — hero banners
  full: "/media/dishes/abc_full.webp", // ~100KB+ — full view
}
```

The discovery feed uses `thumb`. The restaurant menu uses `card`. The restaurant header uses `hero`. We never load a full-resolution image where a thumbnail works.

The variants are sparse by contract. If an item has no image, the backend returns `{}` instead of null URLs. The mobile client renders a fallback icon. This prevented a bug where one imageless dish blanked the entire Discover tab.

## 7\. Cursor pagination, not offset

Every list endpoint uses cursor-based pagination:

```typescript
{ items: [...], next_cursor: "abc123" | null }
```

Offset pagination (`?page=3&limit=20`) means the server counts and skips rows. That’s expensive on large tables and gets messy when new items appear. Cursor pagination just walks forward from where you stopped. It’s faster on the server, more predictable on the client, and works well with background cache updates.

We also prefetch both order scopes (active and past) on first render so tab switching never shows a first-fetch loader:

```typescript
function useWarmOrdersCache(enabled = true) {
  const queryClient = useQueryClient();
  useEffect(() => {
    if (!enabled) return;
    for (const scope of ["active", "past"] as const) {
      void queryClient.prefetchInfiniteQuery({
        queryKey: ordersKey(scope),
        queryFn: ordersPage(scope),
        initialPageParam: null,
      });
    }
  }, [enabled, queryClient]);
}
```

## 8\. Server-side filtering keeps payloads small

Search and discovery filters are sent as query parameters:

```typescript
GET /consumers/discover/?max_price=15000&dietary=vegetarian&sort_by=popular
```

The server does all the filtering and sorting. The client only receives the already-filtered result set. A user looking for cheap vegetarian food in Kampala gets back 15 items instead of 500, and that difference matters on a 50KB data budget.

We also debounce search input by 350ms so we don’t fire a network request on every keystroke.

## 9\. Optimistic updates with rollback

Mutations that should feel instant use optimistic updates. The UI updates right away, and the server call happens in the background:

```typescript
onMutate: async ({ lineId, quantity }) => {
  await queryClient.cancelQueries({ queryKey: ["cart", "mine"] });
  const previous = queryClient.getQueryData(["cart", "mine"]);
  queryClient.setQueryData(["cart", "mine"], (old) => {
    // Update quantity in cache immediately
  });
  return { previous };
},
onError: (_error, _vars, context) => {
  // Rollback on failure
  if (context?.previous) {
    queryClient.setQueryData(["cart", "mine"], context.previous);
  }
},
onSettled: () => {
  // Always reconcile with server
  void queryClient.invalidateQueries({ queryKey: ["cart", "mine"] });
},
```

If the server call fails, the cache rolls back to the previous state. The user sees a brief flicker at worst, not an error dialog.

## 10\. What we deliberately don’t cache

Some things must never be trusted from cache:

*   **Pricing at checkout** — the server re-validates. If the price changed, the user sees “Price updated. Review your order.”
    
*   **Availability** — “3 left” in cache might be “0” on the server. The backend is the authority.
    
*   **Payments** — if you’re offline, you see “You’re offline. Reconnect to complete payment.” We don’t queue payment requests.
    
*   **Order creation** — same as payment. Never queue, never optimistic.
    

The rule is simple: anything financially irreversible waits for the server. We queue follows, saves, and preferences. We never queue money.

## 11\. Auth tokens survive app kills

Tokens live in Expo SecureStore (the device keychain), not AsyncStorage:

```typescript
async function secureGet(key: string): Promise<string | null> {
  if (typeof window !== "undefined" && typeof document !== "undefined") {
    return globalThis.sessionStorage?.getItem(key) ?? null;
  }
  return SecureStore.getItemAsync(key);
}
```

On cold start we restore the session from the keychain, seed a placeholder identity so route guards pass, then hydrate the real identity from the server. If we’re offline, we fail silently:

```typescript
try {
  const account = await getAccount();
  setSession({ id: account.id, email: account.email, kind: "consumer" });
} catch {
  // Offline: keep placeholder, never claim server identity while offline
}
```

On logout we do a four-layer teardown: server revocation, keychain clear, memory clear, disk cache purge. Server failure never blocks local cleanup.

## 12\. Three states, not two

Most apps think in success or error. We think in three states:

```plaintext
FRESH — Here’s today’s data, just fetched.
STALE — Here’s what we last knew. Updating...
UNAVAILABLE — You’re offline and we don’t have this cached.
```

The Today screen, for example:

```typescript
feedQuery.isError && !feedQuery.data ? (
  <Text>
    You’re offline and we don’t have today’s picks saved yet.
  </Text>
) : (
  // render feed (fresh or stale)
)
```

This distinction matters. “Stale” is a feature. It means the app is resilient. “Unavailable” means the user genuinely hasn’t fetched this data yet. They’re different experiences and deserve different UI.

## The principle behind all of this

We didn’t build “an offline app.” We built a resilient server-state architecture that degrades gracefully. The mobile app should feel like:

> “It works even when the network is having a bad day.”

Not:

> “We bolted offline mode onto a normal app.”

Every design decision (data classification, per-type freshness, ETags, image variants, cursor pagination, optimistic updates, the three-state UI) flows from one principle:

**In Africa, the network is unreliable and data is expensive. The app should adapt to that reality, not fight it.**

*PrepOrder is built with Django, Expo (React Native), TanStack Query, and a healthy respect for 2G connections.*
