Offset Pagination
A list field can return thousands of rows. Sending all of them at once is slow and wasteful, so a list takes pagination arguments that ask for one window at a time. The most familiar style — the one every SQL developer already knows — is offset pagination: skip some rows, then take some rows.
limit and offset
Section titled “limit and offset”You expose two integer arguments. limit (sometimes first or pageSize) says how many items to return; offset (sometimes skip) says how many to skip before starting:
type Query { posts(limit: Int = 10, offset: Int = 0): [Post!]!}posts(limit: 10, offset: 0) returns items 1–10. posts(limit: 10, offset: 10) returns items 11–20. The resolver maps these straight onto a slice of the underlying data — and onto LIMIT/OFFSET in SQL, which is exactly why this style is so common.
Page numbers are the same idea
Section titled “Page numbers are the same idea”A page/pageSize API is just offset pagination with friendlier numbers. You convert internally:
type Query { posts(page: Int = 1, pageSize: Int = 10): [Post!]!}The resolver computes offset = (page - 1) * pageSize and proceeds exactly as before. Page numbers are nicer for building “Page 1 2 3” footers; raw offset is nicer for infinite scroll. The mechanics underneath are identical.
The two leaks
Section titled “The two leaks”Offset pagination is simple, but it has two well-known problems.
The first is the deep-offset cost. To return items 100,001–100,010, a database with OFFSET 100000 must still count past the first hundred thousand rows before discarding them. The deeper you page, the slower each page gets — the work grows with the offset, not with the page size.
The second is the shifting window. Offsets address positions, not items. If a new post is inserted at the top while a user is reading, every item shifts down one slot. Page 2 now begins with an item the user already saw on page 1, and one item silently slips through the gap between pages. With deletes, the reverse happens — an item can be skipped entirely.
flowchart TD
subgraph Before["Before insert"]
B1["pos 1: A"]
B2["pos 2: B"]
B3["pos 3: C — page boundary"]
B4["pos 4: D"]
end
subgraph After["After inserting X at top"]
A1["pos 1: X (new)"]
A2["pos 2: A"]
A3["pos 3: B — page boundary"]
A4["pos 4: C"]
end
Before -->|"offset shifts by one"| After Paging for real
Section titled “Paging for real”The runner below holds a small ordered list and resolves posts(limit, offset) by slicing it. Run it as-is to fetch page two; then change offset and re-run to feel how the window moves.
The result returns items 3–4 (offset: 2, limit: 2), plus a totalCount and a hasMore flag the client can use to render a pager. This is offset pagination working well — a small, stable list where deep offsets and concurrent edits are not a concern. The moment your list is large or actively changing, the two leaks above start to bite, which is exactly what the next lesson on cursor connections is designed to solve.
Common mistakes
Section titled “Common mistakes”Offset pagination on a real-time feed
Symptoms:
- a feed that gets new items frequently — a user pages to page 2 but sees the same item as page 1
- offset pagination is not stable against frequently-changing data
- Fix: use cursor pagination (a Relay connection) for feeds
A separate count query on every request
Symptoms:
- every page request runs an extra
COUNT(*) - expensive on a large table — PostgreSQL has to do a sequential scan
- Fix: cache the total count, or use an estimated count instead of an exact one
💡 Real-world examples
Stack Overflow:
- the question list uses offset pagination via the
?page=2URL parameter, visible directly in the site’s own URLs (example: stackoverflow.com/questions?page=2) — shareable and bookmarkableA common admin-page pattern (not tied to one product):
- offset pagination fits admin tables that need random access and page numbers