GraphQL path · Execution · Topic 6 of 12

Pagination with cursor connections

Not started

A GraphQL list field that can grow should be paginated, and the convention most APIs and client libraries use is the **connection** pattern. Instead of returning [Post!]!, the field returns a PostConnection with a list of edges, each holding a node (the item) and a cursor (an opaque marker for that item's position), plus a pageInfo object that says whether there is more and where to continue. The client asks for first: 20, then for first: 20, after: <endCursor>, and so on. Underneath, a good implementation turns the cursor into a keyset condition (WHERE (created_at, id) < (...)), which stays fast on deep pages.

Why it matters: Any list field without a limit is a denial-of-service waiting to happen, and offset paging skips or repeats items when data changes. The connection pattern fixes both in a shape every major client library understands.

  1. 1Never return an unbounded list

    List fields that can grow take `first` (and usually `after`), with a maximum the server enforces.

  2. 2Cursors, not offsets

    Each item carries an opaque cursor that marks its position. The next page starts `after` that cursor, so inserts and deletes don't shift it.

  3. 3The connection shape

    A connection has `edges` (each with a `cursor` and a `node`) and `pageInfo` (`hasNextPage`, `endCursor`, and their backward twins).

  4. 4Edges can carry data

    Facts about the relationship rather than the item, such as when someone joined a team, belong on the edge type.

1

How it works

The explanation, with examples you can run.

The shape

type Query {  posts(first: Int = 20, after: String, last: Int, before: String): PostConnection!} type PostConnection {  edges: [PostEdge!]!  pageInfo: PageInfo!  totalCount: Int          # optional, and can be expensive} type PostEdge {  cursor: String!  node: Post!} type PageInfo {  hasNextPage: Boolean!  hasPreviousPage: Boolean!  startCursor: String  endCursor: String}
query Feed($after: String) {  posts(first: 20, after: $after) {    edges { cursor node { id title } }    pageInfo { hasNextPage endCursor }  }}

first/after page forwards and last/before page backwards. The spec for this pattern grew out of Relay, but it is used well beyond Relay. Fields that describe the relationship rather than either side belong on the edge: TeamMemberEdge.role or joinedAt.

A toy connection

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

The toy finds the cursor's item by scanning an array; a real server decodes the cursor into the sort key and asks the database for rows after it, using an index. It should also reject a cursor it can't decode rather than starting from the beginning.

Choices to make

  • Maximum page size: document it and enforce it; it is also an input to query cost limits.
  • totalCount: a COUNT(*) over a large filtered set can cost more than the page itself. Offer it only where it is cheap, or approximate it.
  • Stable order: cursors need a total order, so add a unique tie-breaker (usually the id) to any sort key.
  • Normalised caches on the client merge pages by the connection's field and arguments, so keep argument names consistent across the schema.
2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.Why are cursors opaque (for example base64 strings) rather than plain row ids?
  2. 2.A feed paged with `offset: 20, limit: 10` gets a new item at the top between two requests. What does the user see?
  3. 3.What should the server do with `friends(first: 100000)`?

0 of 3 right so far. Get all three to finish this topic.