Skip to main content

Pagination, errors and partial data

The three things every consumer hits on day one — and the three things hardest to change later.

Pagination: cursor connections, everywhere, from day one

Offset pagination breaks under concurrent writes (rows shift between pages) and can't be cached or resumed reliably. Use Relay-style connections even if your first client only wants "the top 10":

type Query {
orders(first: Int!, after: String): OrderConnection!
}

type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int
}

Gotchas we've been burned by:

  • Cap first. An uncapped first: 100000 is a self-service denial-of-service endpoint. Cap at the gateway and the subgraph.
  • totalCount is expensive. Make it nullable and resolve it lazily — on large tables it's often a full scan. Many UIs don't actually need it.
  • Cursors are opaque. The moment a client base64-decodes and fabricates cursors, your implementation is frozen forever. Encrypt or sign them if you must.

Errors: three lanes, never mixed

  1. System errors (top-level errors array): timeouts, crashes, malformed queries. These page your on-call.
  2. User errors (in mutation payloads): validation, permissions, business rules. These render in UI.
  3. Absence (null): the thing doesn't exist or the viewer can't see it. Often deliberately indistinguishable, to avoid leaking existence.

Mixing lanes is the root of most GraphQL error-handling misery: business rules thrown as exceptions pollute alerting; system failures modelled as null hide outages.

Partial data is a feature — if you designed for it

GraphQL can return both data and errors. A well-designed graph degrades: the recommendations rail fails, the product page still renders.

This only works if:

  • Cross-service fields are nullable (see Schema design) so failures stop propagating at the boundary.
  • Clients treat null + error-path as "render without this section", not "throw".
  • You monitor field-level error rates, not just request-level 200s — a graph can be "100% up" while a subgraph fails on every request.

:::warning The 200-with-errors trap GraphQL returns HTTP 200 for requests with resolver errors. If your alerting is HTTP-status based, a fully down subgraph looks healthy. Alert on the errors array and field-level metrics from your router/gateway. :::