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.