The Backend Behind the App
Shape an API for phones: small payloads, cursor pagination, ETags, and versioning that keeps old installs working.
GET /v1/posts?limit=20&cursor=<opaque> -> { "items": [...], "nextCursor": "..." | null }Your app is a client on a slow, interrupted network, running on a battery you are spending. An API designed for a web page in the same datacentre usually makes a poor mobile API.
Small payloads
Give lists and detail views different shapes. A feed row needs an id, a title and a thumbnail URL. It does not need the full body, the author's biography, or every comment. One request that returns exactly what a screen draws beats three that return everything about everything.
Pagination by cursor
Offsets break when rows are inserted while someone scrolls: page two repeats a row page one already showed, and the list renders a duplicate key. Return an opaque nextCursor instead, and null when the list ends. That is exactly the shape TanStack Query's useInfiniteQuery wants: an initialPageParam plus getNextPageParam(lastPage, pages). The example renders a scrolling list of titles and appends the next twenty as you reach the bottom.
Cache headers
Send an ETag with every resource and answer If-None-Match with a 304. A 304 costs a few bytes rather than a payload. This is not a browser cache: there is no service worker here, and the native HTTP caches on iOS and Android do not behave identically, so handle 304 in your own client code rather than assuming the platform did it.
Versioning
An old build lives on phones for years. Treat responses as append-only: new fields optional, existing fields never changing type or meaning, removals only in /v2. Return a minimum supported version so the app can ask for an update instead of crashing.
Example
// app/feed.tsx
import { FlatList, StyleSheet, Text } from "react-native";
import { useInfiniteQuery } from "@tanstack/react-query";
type Post = { id: string; title: string };
type Page = { items: Post[]; nextCursor: string | null };
async function fetchPage(cursor: string | null): Promise<Page> {
const query = cursor ? `?limit=20&cursor=${cursor}` : "?limit=20";
const res = await fetch(`https://api.example.com/v1/posts${query}`, {
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export default function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor, // null ends paging
});
// data?.pages ... ?? [] is the same as:
// data === undefined ? [] : data.pages.flatMap((p) => p.items)
const posts = data?.pages.flatMap((page) => page.items) ?? [];
return (
<FlatList // not ScrollView: this list has no end
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text style={styles.row}>{item.title}</Text>}
onEndReachedThreshold={0.5}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
ListFooterComponent={
isFetchingNextPage ? <Text style={styles.row}>Loading</Text> : null
}
/>
);
}
const styles = StyleSheet.create({
row: { padding: 16, fontSize: 16 },
});When to use it
- Design the feed endpoint for an infinite list that must not duplicate rows.
- Cut pull-to-refresh traffic on a screen users open twenty times a day.
- Add a field to a response without breaking the version already on phones.
- Give the app a way to force an update when a breaking API change is unavoidable.
More examples
The headers that make a refresh nearly free
What a correctly shaped exchange looks like on the wire. private matters because a phone response is for one user and must never be stored by a shared proxy. The 304 is the win: a pull-to-refresh on an unchanged feed costs a couple of hundred bytes instead of a full page, which on a weak connection is the difference between instant and a visible spinner.
GET /v1/posts?limit=20 HTTP/1.1
Host: api.example.com
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "a1f3c9"
Cache-Control: private, max-age=60, stale-while-revalidate=600
{"items":[{"id":"1","title":"First"}],"nextCursor":"eyJpZCI6IjEifQ"}
--- the user pulls to refresh 20 seconds later ---
GET /v1/posts?limit=20 HTTP/1.1
Host: api.example.com
If-None-Match: "a1f3c9"
HTTP/1.1 304 Not Modified
ETag: "a1f3c9"
Cache-Control: private, max-age=60A client that sends If-None-Match
Drop this under your API layer and every caller gets conditional requests without knowing about them. The Map is in memory only, so it resets when the app is killed; swap it for AsyncStorage if you want the saving to survive a cold start. Useful mainly for endpoints that are read often and change rarely, such as a config or category list.
// lib/httpCache.ts
type CacheEntry = { etag: string; body: unknown };
const cache = new Map<string, CacheEntry>();
export async function getCached(url: string): Promise<unknown> {
const hit = cache.get(url);
const headers: Record<string, string> = { Accept: "application/json" };
if (hit) headers["If-None-Match"] = hit.etag;
const res = await fetch(url, { headers });
// Check this FIRST: a 304 has no body, so res.json() would throw.
if (res.status === 304 && hit) return hit.body;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body: unknown = await res.json();
const etag = res.headers.get("etag");
if (etag) cache.set(url, { etag, body });
return body;
}Changes an 18-month-old install survives
Two habits that keep old installs alive. Making every new field optional means an old build that ignores it keeps working and a new build has a defined fallback. The version check gives you an exit when a change really cannot be made compatible: the app shows a blocking "update required" screen with a store link instead of throwing on a field that no longer exists.
// lib/compat.ts
// The shape the build from 18 months ago was compiled against.
type PostV1 = { id: string; title: string; publishedAt: string };
// The same endpoint today: one field added, and added as optional.
export type Post = PostV1 & { readingMinutes?: number };
export function readingLabel(post: Post): string {
// post.readingMinutes ?? 1 is the same as:
// post.readingMinutes === undefined || post.readingMinutes === null ? 1 : post.readingMinutes
const minutes = post.readingMinutes ?? 1;
return `${minutes} min read`;
}
// The server also returns the oldest version it still supports.
export function needsUpdate(appVersion: string, minVersion: string): boolean {
const parse = (v: string) => v.split(".").map(Number);
const [a, b, c] = parse(appVersion);
const [x, y, z] = parse(minVersion);
return a < x || (a === x && (b < y || (b === y && c < z)));
}
console.log(readingLabel({ id: "1", title: "Hi", publishedAt: "2026-01-01" }));
// Output: 1 min read
console.log(needsUpdate("1.4.2", "1.5.0"));
// Output: true
console.log(needsUpdate("2.0.0", "1.5.0"));
// Output: false
Discussion