Back to the map

Module 04 · API fundamentals · Lesson 4 of 15

Pagination, filtering and batching

Lists are where APIs fall over at scale. Offset versus cursor pagination and why offsets break on large or changing data, how to design filters that stay indexable, and when to batch requests instead of multiplying them.

CoreMust-knowMid 12 min Updated Nov 2026

Why it matters

Reading one record is easy. Reading "all the orders for this customer" is where latency, database load and correctness problems live: unbounded lists time out, offset pages skip or repeat items while the data changes underneath, and naive clients make one request per item. Every list endpoint is a scaling decision disguised as a convenience, and interviewers probe it because the right answer requires knowing how the database executes the query.

The idea

Never return an unbounded list. Every collection endpoint has a page size with a default and a maximum, a way to get the next page, and a bounded set of filters the storage can serve efficiently.

Offset versus cursor

Offset pagination (?limit=50&offset=5000) is simple and lets clients jump to any page. The database must scan and discard the skipped rows, so page 1,000 costs a thousand times page 1, and if rows are inserted or deleted between requests the client skips or repeats items.

Cursor pagination (also keyset or seek) returns an opaque token encoding the position of the last item, typically its sort key and id: ?limit=50&after=eyJ.... The next query is WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 50, which an index serves in constant time regardless of depth, and which is stable under concurrent changes. The cost is that clients cannot jump to page 37, and the cursor is tied to the sort order.

offset 5000, limit 50scan 5,000 rows, discardreturn next 50cost grows with offset; skips on insertsafter cursor(created_at, id)index seek to positionreturn next 50constant cost; stable under writes
Offset pages scan from the start; a cursor seeks straight to the position via the index. The gap widens with depth.
 OffsetCursor (keyset)
Deep pagesSlow: scans skipped rowsConstant: index seek
Changing dataSkips or repeats itemsStable
Jump to page NYesNo; next/previous only
Total countNatural to showExpensive; estimate or omit
ImplementationTrivialNeeds a unique, indexed sort key; opaque token encoding
Use forSmall admin tables, search result pages with few pagesFeeds, logs, exports, anything large or live

Filtering and sorting that stay fast

Expose filters as query parameters with documented semantics (status=open, created_after=), and only on fields the storage indexes; a filter on an unindexed column is a full scan dressed as a feature. Sort options should be a fixed list, each backed by an index that also contains the pagination tie-breaker. Reject unknown parameters with 400 rather than ignoring them, so a typo does not silently return everything. For complex ad-hoc querying, back the endpoint with a search engine rather than bending the primary database.

Batching: fewer, larger requests

The N+1 problem appears when a client lists 50 orders and then fetches each customer separately. Fix it on either side: embed or include related data (?include=customer), or offer a bulk endpoint (GET /customers?ids=1,2,3 or POST /customers:batchGet) so one request returns many. On the server, a data loader that coalesces lookups within a request does the same for GraphQL resolvers. For writes, a bulk endpoint that accepts up to a bounded number of items and returns per-item results keeps imports from becoming thousands of calls.

Exports and very large results

When a client genuinely needs everything, do not page through millions of rows over HTTP. Offer an asynchronous export: POST /exports returns 202 and a job id; the client polls or receives a webhook; the result is a file in object storage behind a signed URL. It moves the work off the request path and off the primary database.

Trade-offs

Cursors are correct and fast and give up random access and counts. Embedding related data cuts round trips and bloats responses and couples endpoints. Bulk endpoints reduce calls and need careful limits and partial-failure semantics. Each choice is a question of who pays: the client in round trips, the server in work, or the design in complexity.

In an interview

Name the pagination style with its reason: "Cursor-based, keyed on (created_at, id), because the feed is large and live and offset pages would skip items under writes. Default 20, maximum 100. Filters on status and date only, both indexed." Then pre-empt the N+1: "The list embeds the fields the screen needs, and there is a batch endpoint for the rest." For reports: "An asynchronous export job, not a 10,000-page list."

Check yourself

A social feed uses offset pagination. While a user scrolls, new posts arrive at the top. What does the user see on the next page, and what fixes it?

Pagination, filtering and batching · Backplane