Paginating a List

The shop’s products query, as written in the previous chapter, returns every product in one response:

type Query {
  products: [Product!]!
  product(id: ID!): Product
  cart(id: ID!): Cart
  order(id: ID!): Order
}

With about 500 products this is fine. A shop with 50,000 products cannot send all of them to a phone in one response. The registrar’s API had the same problem with 60,000 offerings, and the fix was to split the list into pages: the client says which page it wants and how many items are on a page, and the response carries the page’s items, the page number, the page size, and the total.

GraphQL can use the same design. Here is the products query with two arguments:

type Query {
  products(page: Int = 1, pageSize: Int = 50): ProductPage!
  product(id: ID!): Product
  cart(id: ID!): Cart
  order(id: ID!): Order
}

type ProductPage {
  products: [Product!]!
  page: Int!
  pageSize: Int!
  total: Int!
}

page and pageSize are the two query parameters from the registrar’s API, written as arguments. Each has a default, written after =, so a client that sends neither still gets a page and not the whole list.

Suppose you wanted to enforce a maximum page size of 200. This cannot be done in the schema. The resolver has to enforce it. It can check the value of pageSize and return an error if it exceeds 200. You can also document it in the schema, as a description on the argument, so that it is clear to anyone reading the schema.

type Query {
  products(
    page: Int = 1
    "Cannot exceed 200."
    pageSize: Int = 50
  ): ProductPage!
  product(id: ID!): Product
  cart(id: ID!): Cart
  order(id: ID!): Order
}

"""
A page of products returned by the `products` query.
"""
type ProductPage {
  products: [Product!]!
  page: Int!
  pageSize: Int!
  total: Int!
}

A request and a response

Here is a request for the second page, twenty products at a time, with only the names and prices:

{
  products(page: 2, pageSize: 20) {
    products {
      id
      name
      priceCents
    }
    page
    total
  }
}

The response has the shape of the query:

{
  "data": {
    "products": {
      "products": [
        { "id": "61", "name": "Tall mug", "priceCents": 2400 },
        { "id": "62", "name": "Espresso cup", "priceCents": 1200 }
      ],
      "page": 2,
      "total": 512
    }
  }
}

The client did not ask for pageSize, so it is not there. From total and the page size it sent, the client knows there are 26 pages, and it knows when it has read the last one. A page past the last one gets 200 with an empty list, as it does in the registrar’s API.

Every list field that can grow should be paginated. In the shop that is only products. A cart’s items and an order’s items are lists too, but a cart has a few items, so those fields stay plain lists.

Cursor pagination

Page numbers have a weakness. Suppose the client has read page 2 and asks for page 3. Between the two requests, the merchant adds a product that sorts before the end of page 2. Everything after it moves down by one. The last product of page 2 is now the first product of page 3, and the client sees it twice. If the merchant deletes a product instead, everything moves up by one, and the client skips one. For a catalog that a merchant edits now and then, this is a small problem. For a feed that changes every second, it is a real problem, because the client would see repeats and miss items all the time.

A cursor fixes this. A cursor is a string that marks a position in the list. Instead of “page 3”, the client asks for “the 20 products after this position”. A product added or removed elsewhere in the list does not move that position. GraphQL has a widely used convention for cursor pagination, called connections. Facebook published it in 2015 with Relay, its GraphQL client library, and many public GraphQL APIs use it. Here is what products looks like under that convention:

type Query {
  products(first: Int = 50, after: String): ProductConnection!
}

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

type ProductEdge {
  node: Product!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

The two arguments replace page and pageSize. first is how many products the client wants. after is a cursor, and the client leaves it out for the first page. The field returns a connection, which is the page. Each entry in the page is an edge, and an edge holds the product, as node, and the cursor that marks the product’s position. pageInfo says whether there is another page and gives the cursor of the last product on this one.

Here is a request for the first page, two products at a time to keep the example short:

{
  products(first: 2) {
    edges {
      node {
        id
        name
        priceCents
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

And the response:

{
  "data": {
    "products": {
      "edges": [
        {
          "node": { "id": "61", "name": "Tall mug", "priceCents": 2400 },
          "cursor": "NjE="
        },
        {
          "node": { "id": "62", "name": "Espresso cup", "priceCents": 1200 },
          "cursor": "NjI="
        }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "NjI="
      }
    }
  }
}

endCursor is the cursor of the last edge on the page, product 62. To get the next page, the client sends it back as after:

{
  products(first: 2, after: "NjI=") {
    edges {
      node {
        id
        name
        priceCents
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

The client keeps going until hasNextPage is false. The cursor is opaque: the client stores it and sends it back, and it does not read anything out of it. The server decides what it encodes. Here it is the product’s identifier, base64-encoded, so "NjE=" is "61". A server may change the encoding later without breaking a client, because no client depends on it.

With cursor pagination, the client cannot jump directly to a page. It can only request the pages after its current position. That fits a feed that a person scrolls through, but not a page where someone wants to jump to a particular page number. I chose page numbers for this API because they make more sense for the shop’s clients, and the product catalog does not change often.