skip to content

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

level: middleimportance: should knowfreq 46%

answer

  1. Ask which end owns the fact
  2. Some facts belong to the pairing only
  3. Same item, two parents, different values
  4. The cursor is already such a field
  5. Eventually the link deserves its own type

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.

solid answer

~50 s

An edge represents the *relationship* between the parent object and one item, so it is the right home for any fact that is true only of that pairing. The node is the item, so it holds the item's own intrinsic facts. In a seat-map graph, a `Seat` has a designator of `14C` and 31 inches of pitch wherever you find it, but its price of 2,850 cents, its `BLOCKED` status and the timestamp it was held until are true only on flight `KJ118` — so those are fields on `FlightSeatEdge`, not on `Seat`. The built-in `cursor` field is the canonical example: an item's position exists only within a particular connection. The practical test is: **if the value would differ depending on which parent you traversed from, it belongs on the edge.** Pushing such a field down onto the node makes the same object contradict itself across two parents and corrupts any cache keyed by object identity.

code

graphql · 16 lines
graphql
type FlightSeatEdge {
  node: Seat!
  cursor: String!
  priceCents: Int!
  status: SeatStatus!
  heldUntil: DateTime
}

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

enum SeatStatus { AVAILABLE BLOCKED OCCUPIED HELD }

go deeper

for a junior

Recall that an edge may carry more than node and cursor, and that the cursor itself is an example of a field belonging to the link rather than the item. Recognising edge fields in a schema is enough at this level.

for a middle

Be able to apply the test out loud — does this value change depending on which parent I came from — and justify a concrete placement decision either way, including why the cursor is on the edge.

for a senior

Show that you know the failure mode: relationship data on the node contradicts itself across parents and silently corrupts a normalized client cache. Also name the costs of edge fields, not just their benefits.

for a principal

Own the modelling call: at what point the relationship stops being edge decoration and becomes a first-class type with identity, mutations and events, and how you migrate a published schema across that line without breaking clients.

## The edge is the relationship, not a wrapper Newcomers read `edges { node { ... } }` as pure ceremony — two levels of unwrapping to get at the thing you wanted. That reading is why edge fields are the most under-used part of the connection convention. The correct reading comes from the graph vocabulary the names were taken from: a node is a vertex, an edge is the line joining two vertices, and an edge can carry its own attributes. In a GraphQL connection, the parent object is one vertex, each item is another, and the edge object standing between them is the place to describe the *link*. ## A worked example An airline seat-map graph has two obvious types, and one non-obvious question. ```graphql type Seat { designator: String! pitchInches: Int! hasPower: Boolean! isExitRow: Boolean! } ``` These are facts about a physical seat in an aircraft's cabin configuration. Seat `14C` has 31 inches of pitch on every flight that aircraft ever operates. Now: what does seat 14C *cost*, and is it *available*? Neither is a property of the seat. Both are properties of "seat 14C **on flight KJ118 departing 06:42**". Put them on the edge: ```graphql type FlightSeatEdge { node: Seat! cursor: String! priceCents: Int! status: SeatStatus! heldUntil: DateTime } ``` Now the same `Seat` can appear under two flights with different prices, and nothing contradicts itself. A well-known everyday example of the same modelling is a membership: a person's `joinedAt` and `role` belong to the person-in-that-group pairing, not to the person. ## The decision test Ask: **does this value change depending on which parent I traversed from?** - *Yes* → edge field. Price per flight, status per flight, the cursor itself, a relevance score from the search that produced this page, the timestamp the link was created. - *No* → node field. Designator, pitch, power outlet, exit-row. - *Neither — it describes the whole slice* → connection field, not edge and not node. ## What goes wrong when you get it wrong **Pushing relationship data onto the node** is the common error, usually because a resolver already has the joined row in hand and it is one line cheaper to expose it there. The damage shows up later: - The same object now answers differently depending on the path taken to reach it, which is precisely the invariant a **normalized client cache** relies on. Such a cache stores one entry per object identity and merges fields into it; two flights writing different `priceCents` into the same `Seat` entry means the last write wins and one screen silently shows the other flight's price. This is one of the nastiest bug classes in a GraphQL client because it is invisible in the network payloads — both responses were correct. - The field cannot be answered when the object is fetched on its own, outside any connection, so it must be nullable and mysteriously null in half the contexts it appears in. **Hiding intrinsic data on the edge** is the rarer, milder mistake. It means clients cannot reuse a fragment on `Seat` — a fragment can only select edge fields inside a connection, so a seat-detail component and a seat-list component stop sharing one selection. ## Costs of edge fields, honestly Edge fields are not free, which is why some teams avoid them: - **They are unreachable outside a connection.** If a client already has a seat and wants its price on a given flight, there is no edge to select; it must re-page the connection or you must add another field. Object-refetch mechanisms return the object, and an edge is not one. - **They fragment client code.** A component that renders a seat now needs data from two levels of the response, so the fragment it declares must be spliced in at the edge level rather than the node level. - **Some tooling ignores them.** Codegen and cache normalization handle nodes well because nodes have identity; edges usually do not, so an edge is treated as an anonymous embedded object. ## The alternative: promote the relationship to a type When the relationship grows enough fields, behaviour, or an identity of its own, the honest move is to stop treating it as edge decoration and make it a first-class object. A `SeatAssignment` type — with its own identifier, its own price, its own status, and a `seat: Seat!` field — becomes the connection's node, and the edge falls back to just `node` and `cursor`. The signal to do this is when you want to fetch, mutate or subscribe to the pairing directly. Mutations are the clearest tell: you can return an updated `SeatAssignment` from a mutation, but there is no sensible way to return "an edge" from one. ## In an interview The question is usually asked as "what is the edge for?" and a weak answer is "to hold the cursor". A strong answer names the cursor as one instance of a general rule — the edge holds facts about the link — gives a concrete second example, and then shows judgement about when the relationship deserves its own type instead.

  • A resolver already has the joined row, so why not just expose the per-flight price on the node type?
    Because the node then answers differently depending on the path taken to reach it. A normalized client cache stores one entry per object identity and merges fields into it, so two flights writing different prices into the same seat entry means the last write wins and a screen silently renders another flight's price. It also forces the field nullable, since fetching the seat alone cannot answer it.
  • When would you promote the relationship to its own object type instead of putting fields on the edge?
    When the pairing needs an identity, a mutation, or a subscription of its own. A mutation can return an updated assignment object; there is no sensible way to return an edge. Once the relationship has more than a couple of fields, or anything wants to address it directly, make it the connection's node and let the edge fall back to node and cursor.
  • What is the cost of putting data on the edge rather than the node?
    Edge fields are unreachable outside a connection, so a client holding the object alone cannot get them. They also split a client component's data needs across two response levels, so a reusable fragment on the item type cannot cover them, and cache tooling that keys on object identity generally treats an edge as an anonymous embedded object.

A seat has a fixed width whoever sits in it; what that seat costs is written on the boarding pass, not stamped on the armrest.

saying these in an interview costs you the question

  • Says the edge exists only to hold the cursor
  • Puts per-relationship facts on the node for convenience
  • Claims edge fields are reachable when refetching the object
  • Never considers promoting the relationship to a type
  • Thinks edges may have no fields beyond node and cursor

context