Networking Basics
10 examples for timeouts, cancellation, and parsing at the mobile edge - the foundation every Expo SDK 57 app needs before adding TanStack Query or axios.
Search across all documentation pages
10 examples for timeouts, cancellation, and parsing at the mobile edge - the foundation every Expo SDK 57 app needs before adding TanStack Query or axios.
React Native 0.86 includes a WHATWG fetch implementation backed by native networking stacks. Start from a blank Expo app:
npx create-expo-app@latest MyNetworkApp --template blank-typescript
cd MyNetworkApp
npx expo install zod @react-native-community/netinfoCreate a small API module so screens never call raw URLs:
// src/api/config.ts
export const API_BASE =
process.env.EXPO_PUBLIC_API_URL ?? "https://jsonplaceholder.typicode.com";Tooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3. Test on a physical device over cellular - simulators hide LTE handoff pain.
Mobile fetch mirrors web APIs. Always check response.ok before parsing - HTTP 404 still resolves the promise.
async function getPost(id: number) {
const res = await fetch(`${API_BASE}/posts/${id}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json() as Promise<{ id: number; title: string; body: string }>;
}fetch never throws on 4xx/5xx - you must inspect res.ok or res.statusTypeError - often indistinguishable from offline on first glanceAPI_BASE - never scatter production hosts across feature foldersRelated: Fetch vs axios - when interceptors justify the bundle cost
Unlike browsers, React Native fetch has no built-in timeout option. Combine AbortController with setTimeout:
export async function fetchWithTimeout(
input: RequestInfo | URL,
init: RequestInit = {},
timeoutMs = 10_000
) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(input, { ...init, signal: controller.signal });
return res;
} finally {
clearTimeout(timer);
}
}// Usage
const res = await fetchWithTimeout(`${API_BASE}/posts/1`, {}, 8_000);AbortError on timeout is distinct from network TypeError - classify before showing copyScreens unmount when users navigate away mid-request. Wire AbortController to useEffect cleanup:
import { useEffect, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native";
export function PostDetail({ postId }: { postId: number }) {
const [post, setPost] = useState<{ title: string } | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const res = await fetch(`${API_BASE}/posts/${postId}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setPost(await res.json());
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return;
setError(e instanceof Error ? e.message : "Request failed");
}
})();
return () => controller.abort();
}, [postId]);
if (error) return <Text>{error}</Text>;
if (!post) return <ActivityIndicator />;
return <Text>{post.title}</Text>;
}AbortError in catch - cancellation is expected, not a user-facing failurepostId aborts the previous request automatically via effect re-runqueryKey + built-in cancellation - see TanStack Query on MobileNever cast res.json() blindly. Validate once at the network boundary:
import { z } from "zod";
const postSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
});
export type Post = z.infer<typeof postSchema>;
export async function getPostSafe(id: number): Promise<Post> {
const res = await fetchWithTimeout(`${API_BASE}/posts/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw: unknown = await res.json();
return postSchema.parse(raw);
}.parse throws ZodError - map to "unexpected server response" in UI, not a stack tracez.array(schema) - list endpoints drift more often than detail endpointsMobile users need different copy for offline, timeout, and server errors:
export type NetworkErrorKind = "offline" | "timeout" | "http" | "parse" | "unknown";
export function classifyFetchError(error: unknown, status?: number): NetworkErrorKind {
if (status && status >= 400) return "http";
if (error instanceof Error) {
if (error.name === "AbortError") return "timeout";
if (error instanceof TypeError) return "offline";
}
return "unknown";
}
export function userMessage(kind: NetworkErrorKind): string {
switch (kind) {
case "offline":
return "No connection. Check airplane mode or try again when online.";
case "timeout":
return "This is taking too long. Try again on a stronger signal.";
case "http":
return "The server could not complete this request.";
case "parse":
return "Received an unexpected response. Try again later.";
default:
return "Something went wrong. Please retry.";
}
}Mutations need explicit headers. JSON.stringify on plain objects - FormData is separate (multipart uploads):
export async function createPost(title: string, body: string) {
const res = await fetchWithTimeout(
`${API_BASE}/posts`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ title, body, userId: 1 }),
},
12_000
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return postSchema.parse(await res.json());
}Content-Type: application/json is required - omitting it breaks many Rails/Express parsersBuild search URLs with URLSearchParams - avoids ?foo=undefined and encoding mistakes:
export async function searchPosts(query: string, limit = 20) {
const params = new URLSearchParams({
q: query,
_limit: String(limit),
});
const res = await fetchWithTimeout(`${API_BASE}/posts?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw: unknown = await res.json();
return z.array(postSchema).parse(raw);
}URLSearchParams handles &, spaces, and Unicodeparams.delete("q") when query is blankcursor as a string param; validate shape with ZodOne module attaches bearer tokens, base URL, and default timeout - features import apiGet / apiPost:
// src/api/client.ts
import * as SecureStore from "expo-secure-store";
import { fetchWithTimeout } from "./fetchWithTimeout";
import { API_BASE } from "./config";
async function authHeaders(): Promise<HeadersInit> {
const token = await SecureStore.getItemAsync("access_token");
return {
Accept: "application/json",
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export async function apiGet<T>(path: string, parse: (raw: unknown) => T): Promise<T> {
const res = await fetchWithTimeout(`${API_BASE}${path}`, {
headers: await authHeaders(),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return parse(await res.json());
}npx expo install expo-secure-storeDELETE and some PATCH endpoints return 204 No Content. Calling res.json() throws:
export async function deletePost(id: number, headers: HeadersInit): Promise<void> {
const res = await fetchWithTimeout(`${API_BASE}/posts/${id}`, {
method: "DELETE",
headers,
});
if (res.status === 204) return;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
if (!text) return;
// Some APIs return JSON bodies on DELETE - parse only when present
JSON.parse(text);
}Content-Type before parsing - text/html error pages arrive as 200 from misconfigured proxiesres.text() then JSON.parse when bodies are optional - clearer errors than res.json() on emptyBefore adopting TanStack Query, understand the manual pattern - then replace it:
import { useCallback, useEffect, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { API_BASE } from "../api/config";
import { fetchWithTimeout } from "../api/fetchWithTimeout";
import { postSchema, type Post } from "../api/posts";
export function PostScreen({ id }: { id: number }) {
const [data, setData] = useState<Post | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError(null);
try {
const res = await fetchWithTimeout(`${API_BASE}/posts/${id}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setData(postSchema.parse(await res.json()));
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return;
setError(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
return (
<View style={{ padding: 16 }}>
{loading && <Text>Loading…</Text>}
{error && (
<>
<Text>{error}</Text>
<Pressable onPress={() => load()}>
<Text>Retry</Text>
</Pressable>
</>
)}
{data && <Text>{data.title}</Text>}
</View>
);
}load() - not Updates.reloadAsync() or app restartuseQuery when you need cache, dedupe, and background refetch - TanStack Query on Mobileexport async function requestJson<T>(
path: string,
options: RequestInit & { timeoutMs?: number; parse: (raw: unknown) => T }
): Promise<T> {
const { timeoutMs = 10_000, parse, ...init } = options;
const res = await fetchWithTimeout(`${API_BASE}${path}`, init, timeoutMs);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return parse(await res.json());
}use with Suspense (Optional)If your team adopts Suspense for data, keep the same timeout wrapper - only the consumption site changes. Most Expo apps still prefer TanStack Query for cache semantics on mobile.
res.ok. Fix: Central client throws on !res.ok.AbortController + 8–15 s default.res.ok first.useEffect return () => controller.abort().EXPO_PUBLIC_API_URL in prod without HTTPS guard - Cleartext leaks tokens. Fix: Assert https:// in production client.Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026