The Backend Behind the App

Shape an API for phones: small payloads, cursor pagination, ETags, and versioning that keeps old installs working.

SyntaxGET /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

Example · tsx
// 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.

Example · http
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=60

A 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.

Example · ts
// 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.

Example · ts
// 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

  • Be the first to comment on this lesson.