| name | mobile-api-design |
| description | Design mobile-friendly HTTP APIs with predictable pagination, filtering, sorting, sparse/partial responses, and a consistent error envelope. Use when specifying new endpoints or reviewing existing ones for mobile use. |
Mobile API Design
Instructions
Mobile clients run on flaky networks, long-lived installs, and limited CPU/battery. API shape decisions made on day one outlive any single app version. Design for the pessimistic case: 3G, stale app, cold cache.
1. Resource Shape
Prefer nouns and predictable plurals: /v1/articles, /v1/articles/{id}, /v1/articles/{id}/comments. Keep resource shapes consistent across endpoints -- the same Article object comes back from list, detail, and search.
Every resource has a stable id (ULID or UUIDv7 preferred; monotonic and sortable), created_at and updated_at in ISO 8601 UTC, and a version or etag for optimistic concurrency where applicable.
2. Pagination
Default to opaque cursor pagination. The cursor is a server-encoded token; clients must treat it as opaque bytes.
GET /v1/articles?limit=20&cursor=eyJpZCI6IjAxSFgifQ
{
"items": [ ],
"next_cursor": "eyJpZCI6IjAxSFkifQ",
"has_more": true
}
Implementation (Node/TS, Postgres):
const limit = Math.min(Number(req.query.limit ?? 20), 100);
const cursor = decodeCursor(req.query.cursor);
const rows = await db.query(
`SELECT * FROM articles
WHERE ($1::text IS NULL OR id < $1)
ORDER BY id DESC
LIMIT $2 + 1`,
[cursor?.id ?? null, limit],
);
const hasMore = rows.length > limit;
const items = rows.slice(0, limit);
const nextCursor = hasMore ? encodeCursor({ id: items.at(-1)!.id }) : null;
res.json({ items, next_cursor: nextCursor, has_more: hasMore });
Client consumption (Kotlin, OkHttp):
suspend fun loadPage(cursor: String? = null): Page<Article> {
val url = "https://api.example.com/v1/articles".toHttpUrl().newBuilder()
.addQueryParameter("limit", "20")
.apply { cursor?.let { addQueryParameter("cursor", it) } }
.build()
return http.get(url).parse<Page<Article>>()
}
Default page size 20, max 100. Clamp silently and echo the effective size.
3. Filtering and Sorting
Use flat query parameters, not nested JSON:
GET /v1/articles?author_id=123&tag=mobile&sort=-published_at
- Prefix with
- for descending (sort=-published_at).
- Allow comma-separated multi-sort:
sort=-published_at,title.
- Restrict sortable and filterable fields to an allowlist; reject others with
400 INVALID_QUERY.
- For ranges, use
min_ / max_ prefixes: min_published_at=2026-01-01.
4. Partial Responses (Sparse Fieldsets)
Mobile screens rarely need every field. Support a fields parameter to reduce payload:
GET /v1/articles?fields=id,title,author.name
Server applies a projection before serialization. Unknown field names are ignored with a Warning header rather than rejecting the request.
5. Consistent Error Envelope
Every non-2xx response uses the same shape:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "title must not be empty",
"details": { "field": "title" },
"request_id": "01HX7..."
}
}
code is stable across minor versions. Clients match on code, never on message.
6. Timestamps, Money, and Enums
- Timestamps: RFC 3339 strings in UTC. Never Unix seconds without units.
- Money: integer minor units plus ISO 4217 currency (
{ "amount": 1299, "currency": "USD" }).
- Enums: lowercase_snake strings; clients bucket unknown values as
unknown.
7. Request Identification
Accept X-Request-Id from the client; generate one if missing and echo it. Clients log both their generated id and the server id for cross-referencing.
8. Compression and Caching
Enable gzip and br responses. Set Cache-Control: private, max-age=60 on list reads when data tolerance allows; use ETag + If-None-Match on detail reads to avoid re-downloading unchanged bodies.
Checklist