Skip to content

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.

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.

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.

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
Inserting a new item at the top shifts every position, so a page boundary can duplicate or skip items.

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.

JavaScript

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.

What do the limit and offset arguments do in offset pagination?
Why does a very large offset (e.g. OFFSET 100000) tend to be slow?
What is the "shifting window" problem with offset pagination?