skip to content

Pagination & Object Identity

The conventions the ecosystem standardised on for slicing a list and naming an object globally. Neither is in the specification, which is why interviewers ask where they came from.

part ofGraphQLoverview, primer and where to startread it →
on this pageshow

questions

30

What is a Relay-style connection in GraphQL, and why return edges instead of a plain list?

level: juniorimportance: must knowfreq 71%

answer

  1. A plain list has nowhere to put position
  2. Two wrapper types, one per level
  3. Type names take a suffix
  4. Each item gets its own wrapper
  5. Convention published for one client, adopted widely

basics

~20 s

A connection is an object type that wraps a paged list. It holds an edges list, where each edge carries one item as its node plus that item's opaque cursor, and a pageInfo object describing the slice.

solid answer

~50 s

A connection is the object type the Relay server specification defines for a paged field. Instead of `seatMap: [Seat!]!`, the field returns `SeatConnection!`, which has two required fields: `edges`, a list of `SeatEdge`, and `pageInfo`. Each edge has a `node` — the actual `Seat` — and a `cursor`, an opaque string identifying that item's position in this ordering. The shape exists because a bare list has nowhere to put anything except items: no per-item position to resume from, no place for facts about the *relationship* between the parent and the item, and no place for slice-level metadata. Wrapping the list in a connection object and each item in an edge creates those two slots. The naming convention is a `Connection` and `Edge` suffix on the item's type name. None of this is in the GraphQL specification itself — it is a server convention that became near-universal.

code

graphql · 19 lines
graphql
type Flight {
  seatMap(first: Int, after: String): SeatConnection!
}

type SeatConnection {
  edges: [SeatEdge!]!
  pageInfo: PageInfo!
}

type SeatEdge {
  node: Seat!
  cursor: String!
}

type Seat {
  designator: String!
  pitchInches: Int!
  hasPower: Boolean!
}

go deeper

for a junior

Be ready to draw the four type names from memory and say what each holds: connection wraps edges plus page info, edge wraps one item plus its cursor. Practise writing the selection set with both levels of nesting.

for a middle

Explain why the wrapper earns its keep: the cursor and any relationship-specific field have nowhere to live on a plain list, and the connection type can grow new fields additively. Know that the convention requires edges and pageInfo but leaves nullability to you.

for a senior

Show that you know this is a convention, not the specification, and that adopting it is a decision with costs — deeper documents, more client unwrapping, harder mocking. Be able to say when a small bounded list should stay a plain list.

for a principal

Own the consistency argument: one paging shape across every list field in an organisation's schemas is worth more than each team picking the ergonomically nicest one, because clients, code generators and caches all key off the shape. Be ready to defend that against ergonomics complaints.

## Where the shape comes from The GraphQL specification says nothing about pagination. It defines a type system and an execution algorithm; how you express "give me the next 25 of these" is left entirely to schema authors. What filled that gap is the **Relay server specification** — a short document describing the *Connection* convention, published so that servers could be compatible with a client that expected it. It has since been adopted far beyond that client, and today most GraphQL APIs that page use it. This distinction matters in an interview: connections are a **widespread convention**, not a specified rule, and a candidate who calls them "part of GraphQL" is repeating folklore. ## The four pieces Take a flight's cabin seat map. The naive field is a list: ```graphql type Flight { seatMap: [Seat!]! } ``` The connection version replaces it with a wrapper type: ```graphql type Flight { seatMap(first: Int, after: String): SeatConnection! } type SeatConnection { edges: [SeatEdge!]! pageInfo: PageInfo! } type SeatEdge { node: Seat! cursor: String! } type Seat { designator: String! pitchInches: Int! hasPower: Boolean! } ``` **The connection type** (`SeatConnection`) is the return type of the paged field. The Relay convention requires it to have an `edges` field and a `pageInfo` field; it is free to have more. **The edges field** returns a list whose element type is the edge type. It is a list of wrappers, not a list of seats. **The edge type** (`SeatEdge`) is the per-item wrapper object. It must have a `node` and a `cursor`. **`node`** holds the actual object — the `Seat`. Beware the word: on this leaf "node" means an edge's `node` field, which is a different thing from the `Node` interface used for global object identification, and different again from an AST node. **`cursor`** is a `String` (or a custom scalar that serializes as one) identifying that item's position in this ordering. Clients treat it as opaque and hand it straight back as a paging argument. **`pageInfo`** carries slice-level facts — whether more items exist in either direction, and the boundary cursors. ## Why not just a list Four things a bare `[Seat!]!` cannot express: 1. **Per-item position.** With a plain list, the only way to resume is by index or by guessing a property of the last item. A cursor per item makes "continue after exactly this one" a first-class answer, and it survives inserts and deletes in a way an index does not. 2. **Relationship data.** The edge is the *relationship* between the flight and the seat, and some facts belong to that pairing rather than to either end. Seat 14C is a physical seat with a fixed pitch of 31 inches, but its price of 2,850 cents and its `BLOCKED` status are true only *on this flight*. Those fields go on the edge. 3. **Slice metadata.** "Is there more?" is a fact about the page, not about any seat in it. A list field has no slot for it; the connection object does. 4. **Room to grow.** Adding a field to the connection type — a count, a facet summary, an applied-filter echo — is an additive schema change. There is no equivalent place to add one to `[Seat!]!` without changing the field's type, which is a breaking change. ## Reading a response ```json {"data":{"flight":{"seatMap":{"edges":[ {"cursor":"c2VhdDoxNEM=","node":{"designator":"14C","pitchInches":31}}, {"cursor":"c2VhdDoxNEQ=","node":{"designator":"14D","pitchInches":31}} ]}}}} ``` Two levels of unwrapping — `edges[].node` — is the price of the shape, and it is the most common complaint about it. That extra nesting is exactly why many APIs also add a plural shortcut field; that shortcut is a separate convention with its own trade-offs. ## Nullability The Relay convention constrains the field names, not their nullability. `edges: [SeatEdge!]!` (never null, no null holes) and `edges: [SeatEdge]` are both legal connections. Most schemas make `edges` and `pageInfo` non-null and think carefully about `node`, because a nullable `node` means a client must handle an edge that arrives with nothing in it, while a non-null `node` means a single failed item can blank out the whole edges list. ## What an interviewer is testing Rarely the syntax. Usually two things: whether you know the shape is a *convention* rather than a spec rule, and whether you can articulate that the edge exists to hold something — position and relationship data — that has nowhere else to live. A candidate who says "it is just boilerplate wrapping" has memorised the shape without understanding it.

  • Is the connection shape part of the GraphQL specification?
    No. The GraphQL specification defines the type system and execution and says nothing about pagination. The connection/edge/cursor shape comes from the Relay server specification, a convention originally published so servers could satisfy one client library, then adopted broadly. A server is free to page any other way; nothing validates a schema against the connection convention.
  • Which fields does the convention actually require on a connection type and an edge type?
    A connection type must have `edges` and `pageInfo`. The `edges` field must return a list whose element type is an edge type, and an edge type must have `node` and `cursor`, where `cursor` serializes as a String. Everything else — extra connection fields, extra edge fields, and the nullability of each — is left to the schema author.
  • Why give every item a cursor rather than paging by index into the list?
    An index only means anything against a stable, fully materialised list. A cursor encodes the item's position in the sort order, so resuming after it stays correct when rows are inserted or removed between requests, and it lets the backend translate the page into a keyed range scan rather than skipping rows. It is also opaque, so the server can change what it encodes without a client change.

Think of an airline boarding manifest: the seat exists on its own, but the manifest line is what says where in the sequence this passenger sits and what they paid — facts about the pairing, not about the seat.

saying these in an interview costs you the question

  • Says the connection shape is defined by the GraphQL specification
  • Calls the edge pure boilerplate with no purpose
  • Puts the cursor on the node instead of the edge
  • Confuses an edge's node field with the Node interface
  • Thinks edges must be a list of the item type itself
  • Claims a connection type may not have extra fields

context

open as a page

Why is a GraphQL connection cursor usually base64 text, and what does calling it opaque require of a client?

level: juniorimportance: must knowfreq 61%

basics

~20 s

Base64 is a convention, not a rule: the Relay cursor connections specification only requires a cursor to serialize as a String. Encoding it discourages parsing, so a client must store the cursor and echo it back unchanged.

open as a page

What do the first/after and last/before arguments select on a GraphQL connection?

level: juniorimportance: must knowfreq 72%

basics

~10 s

first/after pages forward: take the first N edges after a cursor. last/before pages backward: take the last N edges before a cursor. Both slice the same fixed ordering, and neither reverses it.

open as a page

What do the Relay server specification's Node interface and node root field give a client?

level: juniorimportance: must knowfreq 56%

basics

~20 s

The Node interface makes every implementing type expose a non-null ID field whose value is unique across the whole schema. The node root field takes one of those identifiers and refetches that object, whatever its concrete type is.

open as a page

In a nested GraphQL connection, does first: 10 mean ten rows total or ten per parent?

level: juniorimportance: must knowfreq 58%

basics

~20 s

Ten per parent. A nested connection field is resolved once for every object in the outer page, and its arguments slice that parent's own children, so an outer page of 23 with first: 10 inside can return 230 leaf rows.

open as a page

Which pagination arguments does the GraphQL specification itself define for a list field?

level: juniorimportance: must knowfreq 58%

basics

~20 s

None. The GraphQL specification defines the type system, documents and execution, not pagination. first and after come from the Relay cursor connections convention; offset and limit are ordinary field arguments a schema author declares and a resolver implements.

open as a page

What fields does PageInfo carry on a GraphQL connection, and which of them can be null?

level: juniorimportance: must knowfreq 63%

basics

~20 s

PageInfo carries four fields: two non-null booleans, hasNextPage and hasPreviousPage, and two nullable strings, startCursor and endCursor. The cursors are nullable because an empty page has no edges, so there is no first or last cursor to report.

open as a page

Is totalCount part of the Relay cursor connection specification?

level: juniorimportance: must knowfreq 50%

basics

~20 s

No. The Relay cursor connection specification fixes edges, node, cursor and pageInfo; it allows extra fields but defines no totalCount. totalCount is a near-universal server convention whose meaning — exact, capped or estimated — each schema decides for itself.

open as a page

Why must a GraphQL cursor connection's sort order include a unique tie-breaker column?

level: juniorimportance: must knowfreq 56%

basics

~20 s

A cursor names a position in an ordered list. If two rows compare equal on the sort key, that position is ambiguous, so the next page can repeat or skip rows. Appending a unique column makes the order total.

open as a page

What does a GraphQL connection cursor have to encode for the next page to resume exactly where the last edge ended?

level: middleimportance: must knowfreq 66%

basics

~20 s

That row's value for every field the connection sorts by, plus a unique tie-breaker. The server turns those values back into a keyset comparison — rows strictly after this position in this order — instead of skipping a count of rows.

open as a page

Why does selecting totalCount on a connection cost a second, more expensive query?

level: middleimportance: must knowfreq 58%

basics

~20 s

The page read stops after one page of rows; the count must visit every row matching the filter, so its cost scales with the match set, not the page. Selecting the field is what makes a client pay for it.

open as a page

A GraphQL connection is sorted only by a non-unique timestamp. What breaks when a client pages through rows that tie on it?

level: middleimportance: must knowfreq 58%

basics

~20 s

Rows that tie on the timestamp have no defined order among themselves, so a cursor inside that block names no single position. Depending on the resume comparison, the client sees the tied rows twice or misses them entirely.

open as a page

For a nested GraphQL connection, why can't one lookup with a single row cap serve first: 10 per parent?

level: seniorimportance: must knowfreq 52%

basics

~10 s

A cap on the whole lookup is a global cap: its rows can all belong to one parent, leaving the rest with empty pages and hasNextPage false. Each parent needs its own top-N slice.

open as a page

What data belongs on a GraphQL connection's edge rather than on the node it wraps?

level: middleimportance: should knowfreq 46%

basics

~20 s

Facts about the relationship between the parent and the item belong on the edge; facts about the item itself belong on the node. The test is whether the value changes depending on which parent you reached the item through.

open as a page

Which combinations of a connection's first/after/last/before must a server reject?

level: middleimportance: should knowfreq 46%

basics

~20 s

Only a negative first or last is an error the Relay pagination algorithm mandates. Supplying both first and last is discouraged but defined. Neither count, and a request above your maximum page size, are unspecified — the server decides.

open as a page

Why does a global object id pack the type name with the local key, and stay opaque?

level: middleimportance: should knowfreq 47%

basics

~20 s

Because one lookup field has to resolve identifiers for every type, the value must say what it points at as well as which row. Opacity keeps the encoding the server's to change, since no caller is entitled to read it.

open as a page

In a nested GraphQL connection, how does a client page one parent's children further?

level: middleimportance: should knowfreq 41%

basics

~20 s

With a second operation that fetches just that one parent and passes that parent's own endCursor to its nested connection. Each nested connection has its own pageInfo and cursors, meaningful only inside the connection that produced them.

open as a page

Why can hasPreviousPage be false on a GraphQL connection page that has items before it?

level: middleimportance: should knowfreq 47%

basics

~20 s

The Relay server specification only requires hasPreviousPage to be accurate when a client pages backward. Paging forward, a server may answer true only if it can cheaply tell that earlier items exist, and false otherwise. False therefore means no, or unknown.

open as a page

A GraphQL connection exposes both edges and a plural nodes field — what do clients lose by selecting nodes?

level: seniorimportance: should knowfreq 37%

basics

~20 s

Selecting nodes skips the edge layer, so clients lose every per-item cursor and every field the edge carries. They can still resume from the slice's boundary cursor, but never from an arbitrary item in the middle.

open as a page

A GraphQL connection's rows change mid-paging — what happens to a cursor whose anchor row was deleted?

level: seniorimportance: should knowfreq 49%

basics

~20 s

Nothing, if the cursor carries the row's sort values: it names a coordinate in the ordering, not the row, so the scan resumes at the same boundary and finds it empty. Only a cursor holding a row reference breaks.

open as a page

A caller decodes a connection cursor, edits it and replays it — how should the server defend itself?

level: seniorimportance: should knowfreq 43%

basics

~20 s

Treat a decoded cursor as untrusted input. Bind values as parameters, carry a fingerprint of the ordering and filters so a replay under different arguments is rejected, and compose the authorization predicate into the same query the cursor seeks within.

open as a page

How do you serve last/before on a GraphQL connection without returning edges reversed?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Flip the ordering and the keyset predicate to read the tail, take N rows, then reverse those rows back before building edges. The fetch order is an implementation detail; the response must stay in the connection's declared order.

open as a page

Global object ids must be re-encoded after a datastore migration — how do you roll that out?

level: seniorimportance: should knowfreq 36%

basics

~20 s

Never in one deploy. Identifiers already handed out live in caches, links and other systems, so the server accepts both encodings for a long overlap, emits only the new one, and retires the old decoder when measured legacy traffic reaches zero.

open as a page

Users paging a GraphQL list field by offset report seeing one row twice and missing another. What causes it?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Offset names a position, not a row. Between the two requests the ordering shifted — an insert before the window, or tied rows swapping places — so one row landed in both pages and another landed in neither.

open as a page

Why can computing hasNextPage make a GraphQL connection field slow, and how do you avoid it?

level: seniorimportance: should knowfreq 39%

basics

~20 s

Because the naive implementation answers it with a second query that counts or scans every row matching the filter, which grows with the data while the page query stays bounded. Fetch one row more than the page size instead, and drop it before building edges.

open as a page

A connection's exact totalCount times out on large filters; what do you offer clients instead?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Start from what the UI needs. Infinite scroll needs only hasNextPage, which the page read already provides. Otherwise offer a capped count, a clearly named estimate, or a count delivered separately so it never blocks the page.

open as a page

A GraphQL connection field takes no ordering argument. What order should the server return, and why must it be pinned?

level: seniorimportance: should knowfreq 45%

basics

~20 s

The server must choose one total order, use it for every request of that field, and document it. "Whatever the backend returned" is not an order — it can differ between two identical requests, which makes every cursor meaningless.

open as a page

How do you set page-size caps across nested GraphQL connections when each level multiplies the leaf count?

level: principalimportance: should knowfreq 31%

basics

~20 s

Budget the product, not each field. Caps that look reasonable in isolation compose multiplicatively, so shrink defaults and maxima with depth, decide per field whether a nested connection is offered at all, and roll any tightening out behind usage telemetry.

open as a page

How can a server keep the Relay connection shape while paging by offset underneath?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

Encode the position in the opaque cursor. The server emits base64 of "offset:42", decodes an incoming after cursor back to a number, and slices with skip and take. The cursor anchors a position, not a row.

open as a page

How would you set one count contract for connections across dozens of independently owned subgraphs?

level: principalimportance: nice to knowfreq 24%

basics

~20 s

Fix meaning by name, not by hope: one name reserved for exact counts, distinct names for capped and estimated ones, mandatory descriptions, nullability everywhere. Enforce it with schema linting at publish time, because composition never checks semantics.

open as a page