How do you decode and paginate an API response, and which pagination style is safest?
Predict first
A generic page envelope is decoded and the next cursor read. What is printed?
import Foundation
struct Page<T: Decodable>: Decodable { let items: [T]; let next: String? }
let json = Data(#"{"items":[1,2,3],"next":"abc"}"#.utf8)
let page = try JSONDecoder().decode(Page<Int>.self, from: json)
print(page.items.count, page.next ?? "end")
- 3 abc
- 3 end
- 0 abc
Predict first
A cursor and a limit are added as query items. How are the special characters shown?
import Foundation
var uc = URLComponents(string: "https://api.test/list")!
uc.queryItems = [URLQueryItem(name: "cursor", value: "a b&c"), URLQueryItem(name: "limit", value: "20")]
print(uc.url!.absoluteString)
- https://api.test/list?cursor=a b&c&limit=20
- https://api.test/list?cursor=a%20b%26c&limit=20
- https://api.test/list?cursor=a+b+c&limit=20
Mental model. Offset pagination is “give me items 40 to 60”, which goes wrong if someone adds an item to the front. A cursor is a bookmark placed in the list itself: “continue after this one”.
Model each response with Decodable types and decode in one place (JSONDecoder, with a configured key and date strategy as covered in the Foundation topic). A paged endpoint returns an envelope: the items plus information about how to continue. Define the envelope as a generic type (struct Page<T: Decodable>: Decodable { let items: [T]; let next: String? }) so every endpoint reuses it. The three pagination styles: offset/limit (?offset=40&limit=20) is simple but unstable (if items are inserted or deleted while the user pages, you skip or repeat items) and slow for large offsets; page numbers have the same drift problem; cursor (?cursor=abc, where the server returns an opaque token for “after this item”) is stable under inserts and efficient, and the client never does arithmetic on it. Prefer cursor pagination; treat the cursor as an opaque string and nil or an empty next as the end.
Client design points. Build query strings with URLComponents and URLQueryItem, which percent-encode values for you (a space becomes %20 and & becomes %26). Load the next page when the user scrolls near the end (onAppear of a late row, or the prefetch method of a collection view), guard against duplicate loads with an in-flight flag or by cancelling the previous task, deduplicate by id when merging pages, and keep the cursor next to the data it belongs to. Handle the empty state, end of list, and errors for a page without discarding the pages already loaded (offer retry on the last page only). Decode tolerantly for list items (skip one malformed item rather than failing the whole page) when the API allows evolution. For large payloads, decode off the main thread, and for very large lists persist to a database and drive the UI from it.
The envelope decodes its three items and the cursor to use for the next request.
The space and the ampersand are percent-encoded so they cannot be confused with query syntax.
Common traps
- Using offset pagination on data that changes. Items are skipped or shown twice as the list shifts; use a cursor.
- Firing the next-page request on every
onAppear. You get duplicate loads; track one in-flight request.
They might follow up with
- How do you know the last page? The cursor is
nilor empty, or the API gives a flag; never infer it from a short page alone. - Why decode in one place? One place for key and date strategies, error mapping and tolerant decoding.
Related questions
- 12.3.2 How does HTTP caching work with Cache-Control, ETag and conditional requests, and how does URLCache fit in?
- 12.6.1 REST, GraphQL or gRPC: what are the client-side trade-offs?
- 2.5.1 What does keyDecodingStrategy do, and when does convertFromSnakeCase not match?
How does HTTP caching work with Cache-Control, ETag and conditional… →