Mobile App Development
ConceptIntermediate 30 min· Real app features · 8 of 12

Offline support and performance

Make your app useful on weak or missing networks: detect connectivity, cache data, keep user data in a local database, sync later, and keep lists and images fast on budget Android phones.

Mobile networks are not like office Wi-Fi. Users ride the metro, sit in lifts, live in areas with weak signal, or run out of data at the end of the month. An app that shows a blank screen or an error whenever the network drops feels broken, even when the code is correct.

This lesson explains how to build apps that keep working with a bad or missing connection, and how to keep them fast on cheap phones. It is a concept lesson, so there are small code examples rather than a full build. The *Project: expense tracker app* lesson uses a local database in a real app.

Online-first vs offline-first

Three levels

ApproachHow it worksGood for
Online-onlyEvery screen calls the server; no network, no dataLive data: chat, maps, trading prices
Cache and retryShow the last saved data, refresh when onlineNews feeds, menus, product lists
Offline-firstThe local database is the source of truth; sync to the server in the backgroundNotes, expense trackers, field-work apps, to-do lists

You do not have to go fully offline-first. Decide per feature. A Brew & Bean menu can be cached. Placing an order needs the server, so show a clear message instead of failing silently.

Detect the network

@react-native-community/netinfo tells you whether the phone is connected. Install it with npx expo install @react-native-community/netinfo. The hook below returns the current state and updates when it changes.

src/OfflineBanner.tsx
import { Text } from 'react-native';
import { useNetInfo } from '@react-native-community/netinfo';

export function OfflineBanner() {
  const { isConnected, isInternetReachable } = useNetInfo();
  // isConnected can be true on Wi-Fi with no internet, so also check isInternetReachable.
  const offline = isConnected === false || isInternetReachable === false;
  if (!offline) return null;
  return (
    <Text style={{ backgroundColor: '#444', color: '#fff', padding: 8, textAlign: 'center' }}>
      You are offline. Showing saved data.
    </Text>
  );
}

Both values can be null while the state is still being checked, so treat only a definite false as offline.

Do not rely on it alone

Network state is a hint, not a promise. Being connected does not mean your server will answer. Always handle failed requests (try/catch, timeouts) even when NetInfo says you are online.

Pattern 1: Cache and show stale data

The simplest offline feature: after a successful fetch, save the result locally. On the next start, show the saved copy immediately, then refresh in the background. The user sees content instantly, even on a slow network. This is often called stale-while-revalidate.

Use AsyncStorage (npx expo install @react-native-async-storage/async-storage) for small JSON such as a menu or settings. It stores strings, so use JSON.stringify and JSON.parse.

src/useMenu.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useEffect, useState } from 'react';

type MenuItem = { id: string; name: string; price: number };
const CACHE_KEY = 'menu_cache_v1';

export function useMenu(apiUrl: string) {
  const [menu, setMenu] = useState<MenuItem[]>([]);
  const [fromCache, setFromCache] = useState(false);

  useEffect(() => {
    let cancelled = false;
    (async () => {
      // 1. Show the saved copy straight away
      const saved = await AsyncStorage.getItem(CACHE_KEY);
      if (saved && !cancelled) {
        setMenu(JSON.parse(saved));
        setFromCache(true);
      }
      // 2. Try to refresh
      try {
        const res = await fetch(`${apiUrl}/menu`);
        if (!res.ok) throw new Error('Bad response');
        const fresh: MenuItem[] = await res.json();
        await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(fresh));
        if (!cancelled) {
          setMenu(fresh);
          setFromCache(false);
        }
      } catch {
        // Offline or server error: keep showing the cached copy
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [apiUrl]);

  return { menu, fromCache };
}

The _v1 in the key lets you change the data shape later without breaking old saved data.

Libraries such as TanStack Query (React Query) do this for you: caching, background refresh, retries and loading states. For most apps that read server data, TanStack Query plus a persisted cache is less work than hand-written code. Learn the hand-written version first so you understand what the library does.

Pattern 2: A local database

When users create data (expenses, notes, tasks), they must be able to add it with no network. Use a local SQLite database through expo-sqlite. It is a real SQL database stored in a file on the phone, so you can filter, sort and sum without loading everything into memory.

AsyncStorage is fine for a few KB. Move to SQLite when you have lists that grow, need queries, or need relations.

Local storage options

StorageBest forLimits
AsyncStorageSettings, small JSON cachesNo queries; whole values loaded; not for large lists
expo-secure-storeTokens and secretsSmall values only
expo-sqliteStructured data, lists, search, reportsYou write SQL and migrations
Files (expo-file-system)Images, downloads, exportsYou manage paths and cleanup

Pattern 3: Sync when the network returns

Offline-first apps must get local changes to the server later. A simple, reliable design:

Every row has a synced flag (0 or 1) and a unique id made on the phone (for example a UUID), not a database counter. When the user adds an item, it is saved locally with synced = 0. A sync function runs when the app starts, when the connection returns, and after each save. It sends all rows with synced = 0 to the server, and marks each as synced only after the server confirms.

src/sync.ts
import * as SQLite from 'expo-sqlite';

type Row = { id: string; title: string; amount: number };

export async function syncPending(db: SQLite.SQLiteDatabase, apiUrl: string) {
  const pending = await db.getAllAsync<Row>('SELECT id, title, amount FROM expenses WHERE synced = 0');
  for (const row of pending) {
    try {
      const res = await fetch(`${apiUrl}/expenses`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(row),
      });
      if (!res.ok) break; // stop and retry later
      await db.runAsync('UPDATE expenses SET synced = 1 WHERE id = ?', row.id);
    } catch {
      break; // network problem: try again on the next run
    }
  }
}

Because ids come from the phone, sending the same row twice must not create a duplicate. The server should treat a repeated id as an update (this is called being idempotent).

Conflicts

If the same record can be edited on two devices, edits can clash. Simple strategies:

Last write wins: keep the version with the newer updated_at. Easy, but one edit is lost. Server wins: the server's copy always replaces the phone's. Merge or ask: combine fields, or ask the user. This is the hardest option and you rarely need it in a first app.

For single-user apps (a personal expense tracker), conflicts are rare and last-write-wins is usually enough.

Performance on real phones

Many users in India use budget Android phones with limited RAM and 4G or weaker networks. Test on a low-end device, not only on your own phone or a fast emulator. Common causes of slow apps and their fixes:

Common problems

ProblemFix
Long list is slow or uses lots of memoryUse FlatList (or FlashList), not ScrollView with .map(). Give each row a stable keyExtractor
Rows re-render on every keystrokeWrap row components in React.memo; keep functions stable with useCallback; don't build new objects inside renderItem when avoidable
Large images load slowlyResize images on the server; use expo-image, which caches; set a width and height
Slow startKeep the first screen light, load heavy screens later, and avoid big work before the first render
Animation stuttersPrefer react-native-reanimated, which runs animations off the JavaScript thread
Too many network callsBatch requests, cache results, and paginate lists (load 20 items, then more)
Large app downloadRemove unused libraries and images; Android App Bundles already deliver only what each device needs
src/BigList.tsx
import { FlatList, Text } from 'react-native';
import { memo, useCallback } from 'react';

type Item = { id: string; title: string };

const Row = memo(function Row({ title }: { title: string }) {
  return <Text style={{ padding: 12 }}>{title}</Text>;
});

export function BigList({ data }: { data: Item[] }) {
  const renderItem = useCallback(({ item }: { item: Item }) => <Row title={item.title} />, []);
  return (
    <FlatList
      data={data}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
      initialNumToRender={12}
    />
  );
}

FlatList only renders rows near the screen. memo stops a row from re-rendering when its props did not change.

Profile first

Measure before you optimise. Open the React Native dev menu or use the React DevTools profiler and look for the screen that is actually slow. Guessing wastes time. Release builds are much faster than development mode, so judge speed only on a release (or preview) build.

Design for bad connections

Offline checklist

  1. Show saved data first; show a spinner only when there is nothing to show.
  2. Tell the user when they are offline, in plain words, and what still works.
  3. Set a timeout on requests so the app does not wait forever. Use AbortController with fetch.
  4. Disable or queue actions that truly need the server, and explain why.
  5. Retry failed requests a few times with increasing delays, never in a tight loop.
  6. Keep payloads small. Ask the server only for the fields you show.
  7. Never lose user input: save drafts locally before sending.

How this comes up in interviews

Typical questions: "How would you make this app work offline?", "How do you sync local data to a server?" and "How do you make a list of 10,000 items scroll smoothly?" A strong answer names the storage choice (SQLite for lists, SecureStore for tokens), the synced flag and retry on reconnect, how conflicts are resolved, and FlatList with memoised rows.

Try it: Add a cache to one screen

Take any screen of yours that fetches data and add the cache-then-refresh pattern from this lesson. Test it: load the screen online, then turn on airplane mode, close the app completely, reopen it. The data should still appear. Add the OfflineBanner too.

Key takeaways

  1. Decide per feature: online-only, cache-and-retry, or offline-first.
  2. Cache successful fetches and show them first, then refresh (stale-while-revalidate).
  3. Use SQLite for user-created data that must work offline; AsyncStorage for small caches.
  4. Sync with a synced flag, ids created on the phone, and retries when the network returns.
  5. Network status is only a hint: always handle failed requests and timeouts.
  6. Use FlatList, memoised rows, resized images and pagination; test on a cheap phone with a release build.

Check yourself

Finishing the quiz marks the lesson as done.

1. A user adds an expense with no network. Which design lets the app keep working?

2. What does stale-while-revalidate mean?

3. useNetInfo says isConnected is true. What is the safest assumption?

4. Why create row ids on the phone (for example UUIDs) in an offline-first app?

5. Which is the best fix for a very long list that scrolls slowly?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free