← Blog

Offline, API Down, or Slow: Show Saved Data and the Right Message

6 min read
Watch the walkthrough on YouTube

You open an app on your phone with full Wi-Fi bars. Instead of seeing categories or recent products, you stare at a spinning loader over a blank canvas. After ten seconds, a red banner drops down claiming you are offline. You check your network settings: your connection is completely fine. The company's backend was simply returning an HTTP 503 error.

Connected is not reachable, and reachable is not your API being up. Render saved data first, then refresh it from the network.

This is Day 14 of Ship Native, the finale of our three-part module on State and offline architecture. In Day 11: Why the Cart Quantity Jumps Back After You Tap +, we tackled pending local state. In Day 12: Why Customers Came Back as Guests, and What Logout Must Remove, we covered store rehydration and clean sign-outs. Today we finish the module by examining what users see when networks fail: why apps display the wrong error message, and how to render saved content instantly from disk while the network refetches in the background.

The companion video features schematic diagrams, synthetic narration, and timed bilingual subtitles. The code examples below represent production patterns tested in active consumer apps.

The symptom: an offline message that lies

In our consumer application Liana, an outage on our catalog service triggered hundreds of customer support inquiries. Users submitted screenshots of a red banner reading "You are offline. Please check your connection."

Many of those customers were connected to high-speed home fiber networks. The app confused an API service downtime with a local network failure. The users felt gaslit: they knew their Wi-Fi was functioning, yet the app insisted the problem was on their end.

Worse, while the app was waiting for the failing API request to time out, the screen remained completely empty. Cached categories and home feeds that existed on the phone were kept hidden behind a full-screen loading spinner.

Three distinct networking realities

Mobile applications must distinguish between three separate layers of network health:

  1. Interface link (state.isConnected): Indicates whether a physical network interface is active. A cellular modem or a connection to a local Wi-Fi router sets this to true.
  2. Internet reachability (state.isInternetReachable): Indicates whether actual packets can reach the open web. On captive hotel or airport portals, isConnected is true, but isInternetReachable is false until the login form is submitted.
  3. Backend service health: Indicates whether your specific API servers are responding with HTTP 200 success codes.

When an API fails with a 500 or 503 status code while the device has full internet access, it is a server issue. Displaying "You're offline" is factually incorrect and misleads the customer.

The banner expression and online manager

In our enterprise application Podium, we evaluate network status using an expression that avoids cold-boot flicker:

// Podium useNetworkStatus.ts:11
const isOnline = Boolean(
  state.isConnected && state.isInternetReachable !== false
);

Notice the strict inequality !== false. When an application cold-boots, NetInfo returns isConnected: true immediately, but isInternetReachable is initially null while the background ping runs. Treating null as offline would cause a red error banner to flash on screen on every startup. Treating unknown as online keeps the interface steady.

To coordinate with your data fetching layer, wire onlineManager directly to NetInfo:

// Podium queryClient.ts:10-11
import { onlineManager } from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';

onlineManager.setEventListener((setOnline) =>
  NetInfo.addEventListener((state) => {
    setOnline(Boolean(state.isConnected && state.isInternetReachable !== false));
  })
);

When network connection drops, TanStack Query pauses outgoing requests. When the connection recovers, queries configured with refetchOnReconnect: true automatically refetch in the background.

The four-phase recovery state machine

In Liana, we expanded the network hook into a four-phase state machine:

  1. online: Normal operation; banner hidden.
  2. offline: Network disconnected; persistent warning banner shown, and auto-retry timer starts (running every 10 seconds).
  3. reconnecting: User tapped manual retry or auto-retry timer fired; spinner shown inside banner.
  4. back-online: Connection restored; banner turns green, displays "Back online", and automatically dismisses after 2.5 seconds.

This transition gives users confidence that their network state has stabilized before they resume interacting with checkout or forms.

Disk as the first source: read-through caching

Never make your users wait for a cold network response before displaying content that was already rendered yesterday.

In Liana, we implemented a read-through disk cache using MMKV. When fetching catalog categories, the query writes fresh responses to disk:

export const useCategoriesQuery = (language: string) =>
  useQuery({
    queryKey: ['categories', language],
    queryFn: async () => {
      const data = await catalogApi.fetchCategories();
      offlineCache.set('categories', language, data);
      return data;
    },
    // Seed initialData immediately so user never sees a blank spinner:
    initialData: () => offlineCache.get<Category[]>('categories', language) ?? undefined,
    // Inform TanStack when data was cached so it can evaluate freshness against staleTime:
    initialDataUpdatedAt: () => offlineCache.updatedAt('categories', language),
    staleTime: 2 * 60 * 1000, // 2 minutes
  });

Because initialData reads synchronously from local storage during the first render pass, category buttons and layouts appear on screen in under ten milliseconds.

Passing initialDataUpdatedAt tells TanStack Query exactly when the cached data was written. If the data is older than staleTime, TanStack immediately triggers a background fetch while the user is already browsing the cached layout.

Language keys and parse protection

Two subtle traps often undermine mobile disk caches:

  1. Language leakage: When an API localizes responses based on the Accept-Language header, caching under a generic key like 'categories' causes problems. If an Arabic user toggles to English, reading from the unkeyed cache briefly renders Arabic text in an English layout. Key every disk record by both scope and locale: ${scope}.${language}.
  2. Unguarded JSON parsing: If an earlier app release stored a schema structure that changed, or if a partial write occurred during a crash, calling JSON.parse directly on boot will throw an uncaught SyntaxError. Always wrap disk cache reads in a try-catch block and treat parse failures as cache misses.
const key = (scope: string, lang: string) => `${scope}.${lang}`;

export const offlineCache = {
  get<T>(scope: string, lang: string): T | null {
    const raw = storage.getString(key(scope, lang));
    if (!raw) return null;
    try {
      return JSON.parse(raw) as T;
    } catch {
      // Treat corrupted or outdated schema as a cache miss
      return null;
    }
  },
};

Architectural limitations

Displaying cached content requires care with time-sensitive information. If an e-commerce screen displays flash sale countdowns or product stock counts, showing stale cached quantities without an indicator can frustrate users who attempt to buy out-of-stock items.

When rendering data seeded from initialData while a background refetch is pending (isFetching && !isLoading), display a subtle "Updating..." indicator or a timestamp so the user knows the view is refreshing.

Verification steps

To verify offline resilience and banner accuracy in your own app:

  1. Launch the app and confirm that category lists render immediately from disk with zero loading spinner.
  2. Toggle airplane mode on your device or simulator: verify that the offline banner appears and states that the network is disconnected.
  3. Re-enable network: confirm that the banner shifts to "Back online" in green, dismisses after 2.5 seconds, and triggers background query refetches.
  4. Simulate captive portal or DNS failure (connect to Wi-Fi with external routing disabled): verify that isInternetReachable: false triggers the unreachable banner even though Wi-Fi is active.
  5. Mock an API 500 error while online: verify that the app displays a server downtime message, never claiming the user is offline.
  6. Toggle application language between English and Arabic: verify that cached records remain isolated by language.

Series roadmap

← All posts

Comments

No account needed.

  1. Loading comments…