GraphQL Pagination Patterns

Pagination breaks large lists into smaller chunks. Returning 10,000 products in one query would crash the browser and overload the database. Pagination returns a manageable slice of data and gives the client a way to ask for the next slice.

Three Pagination Strategies

  Strategy          How it works               Best for
  ────────          ────────────               ────────
  Offset-based      Skip N items, take M       Simple admin tables
  Cursor-based      Start after item X         Infinite scroll, feeds
  Page-based        Return page number N       Traditional page buttons

Offset Pagination (Limit + Offset)

Offset pagination uses two arguments: limit (how many items to return) and offset (how many items to skip). It maps directly to SQL's LIMIT and OFFSET.

  Schema:
  ────────
  type Query {
    products(limit: Int!, offset: Int!): [Product!]!
  }

  Page 1 — first 10:          Page 2 — next 10:
  ───────────────────          ──────────────────
  { products(                  { products(
    limit: 10, offset: 0       limit: 10, offset: 10
  ) { name price } }          ) { name price } }

  Visual:
  ────────
  Products: [1,2,3,4,5,6,7,8,9,10,11,12,13,14,...]
             └────────────┘ offset:0 limit:10
                           └────────────┘ offset:10 limit:10

Offset pagination is simple to implement but has a flaw: if items are added or deleted between page requests, items can appear twice or be skipped. A new item at position 1 pushes everything down, so page 2 now starts one item earlier than the client expects.

Page-Number Pagination

Page-number pagination is a friendlier version of offset pagination. The client sends a page number instead of calculating offsets. The server computes the offset internally.

  Schema:
  ────────
  type PaginatedProducts {
    items:       [Product!]!
    totalItems:  Int!
    totalPages:  Int!
    currentPage: Int!
  }

  type Query {
    products(page: Int = 1, perPage: Int = 20): PaginatedProducts!
  }

  Query:
  ───────
  {
    products(page: 2, perPage: 10) {
      items { name price }
      totalItems
      totalPages
      currentPage
    }
  }

  Response:
  ──────────
  {
    "products": {
      "items":       [{ "name": "Mouse" }, ...],
      "totalItems":  247,
      "totalPages":  25,
      "currentPage": 2
    }
  }

When to Use Offset vs Cursor Pagination

  Offset / Page-number:              Cursor-based:
  ─────────────────────              ─────────────
  Data does not change often         Real-time or frequently updated data
  Users navigate to specific pages   Infinite scroll / "Load more" button
  SQL database with simple queries   Consistent results despite insertions
  Admin dashboards                   Social media feeds, chat history
  Small to medium datasets           Very large datasets

Key Points

  • Never return unbounded lists in production — always paginate.
  • Offset pagination is simple but can miss or duplicate items when data changes between requests.
  • Page-number pagination is a client-friendly wrapper around offset pagination.
  • Cursor-based pagination (covered in the next topic) solves the consistency problem for live data.
  • Return metadata (totalItems, totalPages) alongside the items so the UI can render controls correctly.

Leave a Comment