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 uncappedfirst: 100000is a self-service denial-of-service endpoint. Cap at the gateway and the subgraph. totalCountis 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
- System errors (top-level
errorsarray): timeouts, crashes, malformed queries. These page your on-call. - User errors (in mutation payloads): validation, permissions, business rules. These render in UI.
- 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.
:::