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
| Approach | How it works | Good for |
|---|---|---|
| Online-only | Every screen calls the server; no network, no data | Live data: chat, maps, trading prices |
| Cache and retry | Show the last saved data, refresh when online | News feeds, menus, product lists |
| Offline-first | The local database is the source of truth; sync to the server in the background | Notes, 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.
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.
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
| Storage | Best for | Limits |
|---|---|---|
AsyncStorage | Settings, small JSON caches | No queries; whole values loaded; not for large lists |
expo-secure-store | Tokens and secrets | Small values only |
expo-sqlite | Structured data, lists, search, reports | You write SQL and migrations |
Files (expo-file-system) | Images, downloads, exports | You 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.
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
| Problem | Fix |
|---|---|
| Long list is slow or uses lots of memory | Use FlatList (or FlashList), not ScrollView with .map(). Give each row a stable keyExtractor |
| Rows re-render on every keystroke | Wrap row components in React.memo; keep functions stable with useCallback; don't build new objects inside renderItem when avoidable |
| Large images load slowly | Resize images on the server; use expo-image, which caches; set a width and height |
| Slow start | Keep the first screen light, load heavy screens later, and avoid big work before the first render |
| Animation stutters | Prefer react-native-reanimated, which runs animations off the JavaScript thread |
| Too many network calls | Batch requests, cache results, and paginate lists (load 20 items, then more) |
| Large app download | Remove unused libraries and images; Android App Bundles already deliver only what each device needs |
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
- Show saved data first; show a spinner only when there is nothing to show.
- Tell the user when they are offline, in plain words, and what still works.
- Set a timeout on requests so the app does not wait forever. Use
AbortControllerwithfetch. - Disable or queue actions that truly need the server, and explain why.
- Retry failed requests a few times with increasing delays, never in a tight loop.
- Keep payloads small. Ask the server only for the fields you show.
- 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
- Decide per feature: online-only, cache-and-retry, or offline-first.
- Cache successful fetches and show them first, then refresh (stale-while-revalidate).
- Use SQLite for user-created data that must work offline; AsyncStorage for small caches.
- Sync with a
syncedflag, ids created on the phone, and retries when the network returns. - Network status is only a hint: always handle failed requests and timeouts.
- 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.