GraphQL Cursor-Based Pagination

Cursor-based pagination uses a pointer — called a cursor — to mark your position in a list. Instead of asking "give me items 20–30", you ask "give me 10 items after this specific item". The result is stable even when new items are inserted or deleted, making it the correct choice for live feeds and infinite scroll.

The Cursor Is a Bookmark

  Offset pagination (position-based):
  ─────────────────────────────────────
  Items: [A B C D E F G H I J K L ...]
                       ↑
                       offset=5, get next 5
  Problem: If D is inserted after first page loads,
           offset=5 now starts at E (skipped D)

  Cursor pagination (item-based):
  ─────────────────────────────────
  Items: [A B C D E F G H I J K L ...]
                     ↑
                     cursor points to E
  "Give me 5 items after E" → [F G H I J]
  No matter what is inserted, cursor stays anchored to E

The Relay Connection Specification

The most widely adopted cursor pagination standard in GraphQL is the Relay Connection Spec. It uses a specific shape that many client libraries understand automatically. Learning this shape once means you can work with any API that follows it.

  Connection shape:
  ──────────────────
  type ProductConnection {
    edges:    [ProductEdge!]!
    pageInfo: PageInfo!
  }

  type ProductEdge {
    node:   Product!    ← The actual item
    cursor: String!     ← The cursor for this item
  }

  type PageInfo {
    hasNextPage:     Boolean!
    hasPreviousPage: Boolean!
    startCursor:     String
    endCursor:       String
  }

  type Query {
    products(first: Int, after: String,
             last:  Int, before: String): ProductConnection!
  }

Forward Pagination (first + after)

  First page — 5 products:
  ──────────────────────────
  {
    products(first: 5) {
      edges {
        cursor
        node { name price }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }

  Response:
  ──────────
  {
    "products": {
      "edges": [
        { "cursor": "cur_1", "node": { "name": "Laptop",   "price": 899 } },
        { "cursor": "cur_2", "node": { "name": "Monitor",  "price": 299 } },
        { "cursor": "cur_3", "node": { "name": "Keyboard", "price":  59 } },
        { "cursor": "cur_4", "node": { "name": "Mouse",    "price":  29 } },
        { "cursor": "cur_5", "node": { "name": "Webcam",   "price":  79 } }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor":   "cur_5"
      }
    }
  }

  Next page — use endCursor as the "after" value:
  ─────────────────────────────────────────────────
  {
    products(first: 5, after: "cur_5") {
      edges { cursor node { name price } }
      pageInfo { hasNextPage endCursor }
    }
  }

What Cursors Actually Are

A cursor is typically the base64-encoded ID or timestamp of an item. The client treats it as an opaque string — it never parses the cursor itself. Only the server knows how to decode it.

  Internal cursor value:    "product:101"
  Sent to client as:        Base64 → "cHJvZHVjdDoxMDE="
  Client passes it back:    after: "cHJvZHVjdDoxMDE="
  Server decodes it:        "product:101" → WHERE id > 101

Backward Pagination (last + before)

  Go backward through a list:
  ────────────────────────────
  products(last: 5, before: "cur_10") {
    edges { node { name } }
    pageInfo { hasPreviousPage startCursor }
  }

Key Points

  • Cursor pagination anchors to an item, not a position, so it stays consistent when data changes.
  • The Relay Connection Spec (edges, node, cursor, pageInfo) is the standard cursor pagination shape in GraphQL.
  • Use first + after for forward navigation; last + before for backward navigation.
  • Clients treat cursors as opaque strings — only the server decodes them.
  • Cursor pagination is the correct choice for real-time feeds, chat history, and infinite scroll.

Leave a Comment