Designing Pagination-Friendly Schemas

Large collections should not normally be returned without bounds.

A naive schema might expose:

users: [User!]!

This becomes dangerous if the database contains millions of users.

A basic pagination design could use:

users(limit: Int!, offset: Int!): [User!]!

But large-scale APIs often use cursor-based pagination because cursors can provide more stable traversal semantics when data changes during pagination.

A connection-style design can expose metadata:

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

The client can request:

users(first: 20, after: $cursor) {
  edges {
    node {
      id
      name
    }
    cursor
  }
  pageInfo {
    hasNextPage
    endCursor
  }
}

This structure is more verbose than returning a raw list, but it provides room for pagination metadata and future evolution.

Pagination strategy should match the underlying data store and access patterns. Cursor pagination is not a magical performance solution by itself; the cursor design and database query still matter.

Pagination Models
Offset: limit + offset
Simple to understand but can become inefficient or unstable for large, frequently changing datasets.
Cursor: after + cursor
Supports traversal based on a position in an ordered result set.
Connection: edges + pageInfo
Provides a structured contract for nodes, cursors, and pagination metadata.
Key Takeaways
  • Avoid unbounded collection fields in production APIs.
  • Pagination should be designed into collection operations.
  • Cursor-based pagination can provide stable traversal semantics.
  • Connection types expose useful metadata around paginated results.
Knowledge Check

Why is returning an unbounded list such as `users: [User!]!` risky in a production API?