GraphQL

Pagination And Global Object Identification

Why Unbounded Lists Must Go

Artist.albums: [Album!]! is fine at fifteen albums; Query.reviews: [Review!]! returning four million rows is an outage. The security lesson already demanded caps on list fields; pagination is the client-facing shape those caps take. Two families exist.

Offset Pagination: Simple, Flawed

The SQL-familiar approach exposes limit/offset (or a page number):

{
  reviews(limit: 20, offset: 40) {
    rating
    body
  }
}

It is trivial to implement and supports "jump to page 7". Its problems appear under churn and scale: if a review is inserted while a user pages, offsets shift and items duplicate or vanish between pages; and deep offsets are slow in most databases (the store must count past all skipped rows). Offset paging is acceptable for small, mostly static, admin-style lists — and wrong for feeds.

Cursor Pagination And The Connection Pattern

Cursor pagination replaces "skip N" with "continue after this item": each item carries an opaque cursor, and the client asks for items after the cursor it last saw. Stable under inserts, efficient in the database (an indexed WHERE created_at < ? instead of OFFSET).

The Relay connection spec standardizes the shape:

{
  artist(slug: "nova-tide") {
    reviewsConnection(first: 20, after: "cur_x9f2") {
      edges {
        cursor
        node {
          rating
          body
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
      totalCount
    }
  }
}

Reading the structure:

  • the connection wraps the whole page and carries page-level data (pageInfo, optionally totalCount);
  • each edge wraps one item, pairing the item (node) with its cursor — and giving relationship-specific data a home (an edge on a followers connection might carry followedAt, which belongs to the relationship, not to either user);
  • pageInfo tells the client whether and how to continue: request the next page with after: endCursor. Backward paging uses the mirror arguments last/before and hasPreviousPage/startCursor.

Cursors are opaque: typically a base64-encoded position the server can decode, but clients must treat them as meaningless tokens obtained from the server, never constructed or compared. Opacity is what lets the server change its paging strategy without breaking anyone.

The cost of connections is verbosity — three levels of nesting to reach items. The trade is worth it for anything feed-like or shared with external consumers; internal APIs sometimes flatten to nodes { ... } plus pageInfo and skip edges when no relationship data exists. Whichever you pick, enforce a server-side maximum on first.

Global Object Identification: The Node Pattern

Separately, clients — especially caching clients — need to answer "I have some object; fetch it again by itself." That requires two things the Relay spec also standardized:

  1. every identifiable object exposes an id: ID! that is globally unique across the whole schema, not just per table;
  2. a single root field refetches anything by that id:
interface Node {
  id: ID!
}

type Album implements Node {
  id: ID!
  title: String!
}

type Query {
  node(id: ID!): Node
}
{
  node(id: "QWxidW06YWxfMzAx") {
    ... on Album {
      title
      averageRating
    }
  }
}

Global uniqueness is usually achieved by encoding the type name with the database id (Album:al_301, base64-encoded) — opaque to clients, decodable by the server, and collision-free even though albums and artists both have a row 301. Normalized client caches key their store on these ids, and the generic node field is how they refresh any stale object without knowing type-specific query fields.

The spec also allows plural identifying root fields — batch lookups like nodes(ids: [ID!]!): [Node]!, or domain-specific ones such as artistsBySlugs(slugs: [String!]!): [Artist]! — which return results in the same order as the input list (with null holes for misses) so clients can zip inputs to outputs. They save clients a round trip per object when hydrating many cached items at once. Global ids should also be stable: refetching the same object must always work with the id handed out earlier, so never derive ids from mutable data like slugs or names.

Two cautions. Ids that encode type names are trivially decodable — never treat their opacity as secrecy, and remember the node field is one more doorway your business-layer authorization must already cover (a good test of the previous lesson's design: node(id:) should leak nothing that Query.album(id:) would not). And if you adopt global ids, use them everywhere an id appears, including mutation inputs, so clients never juggle two id vocabularies.

Adopt Deliberately

None of this is required by the GraphQL spec. But these conventions are what mature tooling expects, and they encode a decade of API scar tissue: unbounded lists fail, offsets skew, per-table ids collide in caches. Deviate when you have a reason — not from unfamiliarity.

What To Check

Before moving on, make sure you can:

  • explain the two failure modes of offset pagination and when it is nonetheless fine
  • read a connection query and walk the roles of connection, edge, node, cursor, and pageInfo
  • say what belongs on an edge rather than on the node, with an example
  • explain why cursors and global ids must be opaque to clients
  • describe how Node + node(id:) enables cache refetching, and its authorization implication

What You Should Be Able To Do

After this lesson, you should be able to design list fields that survive production: pick offset or cursor paging per use case, shape a connection with sensible caps, mint globally unique opaque ids, and wire a node refetch field whose access control matches the rest of the graph.