skip to content

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