In a nested GraphQL connection, how does a client page one parent's children further?
answer
- Whose list is being paged here
- Cursors belong to one connection only
- Outer page info describes parents
- Refetch the parent, not the page
- Key client state by parent id
basics
~20 sWith 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.
solid answer
~50 sEvery nested connection in a response is a connection in its own right: its pageInfo describes that parent's children and its cursors index that parent's ordered child set alone. So "load more scan events for waybill AWB-8814" is not a change to the outer page — it is a second, small operation that addresses that one shipment and asks `scanEvents(first: 10, after: <that connection's endCursor>)`. Re-running the outer shipments page with a larger inner `first` refetches twenty-two shipments nobody asked about, re-sends rows the client already holds, and still is not a resume point. Two traps follow. The outer connection's `hasNextPage` is about shipments and says nothing about any child list. And cursors are scoped to the connection that produced them: feeding one shipment's endCursor into another's `after` argument is at best an error and at worst a plausible-looking wrong page.
code
graphql · 8 linesquery MoreScans($id: ID!, $after: String!) {
shipment(id: $id) {
scanEvents(first: 10, after: $after) {
edges { cursor node { scannedAt facilityCode } }
pageInfo { hasNextPage endCursor }
}
}
}go deeper
Know that each nested list in the response has its own pageInfo and its own cursors, and that continuing one of them means asking about that one parent again rather than requesting the whole outer page a second time.
Be able to write the follow-up operation: address the single parent, pass that connection's endCursor as after, and keep cursor state keyed by parent identity. Explain why a cursor is meaningless outside the connection that issued it.
Demonstrate the failure modes you have seen — shared cursor state that only misbehaves on the second expanded card, and edge-count heuristics standing in for hasNextPage — and how you would catch them before production skew does.
Frame the asymmetry as a design lever: because continuation is cheap and per parent, inner defaults can stay small while outer pages stay generous, which bounds the multiplied response without hurting the common screen.
## Every nested connection is a connection in its own right When a connection field is selected under a list of parents, the response contains one complete connection object per parent: its own `edges`, its own `pageInfo`, its own cursors. Those parts describe **that parent's** ordered child set and nothing else. In a freight-tracking graph, a board that pulls twenty-three shipments and the first ten scan events of each comes back with twenty-three `scanEvents` connections, and "show more scans for waybill AWB-8814" is a question about exactly one of them. ## Continuation is per parent, so the follow-up operation addresses one parent The natural instinct — re-run the outer page with a larger inner `first` — is wrong on three counts. It refetches twenty-two shipments nobody asked about. It refetches the ten scan events the client already has. And it is not a resume point: enlarging `first` re-slices from the beginning of that parent's ordering, so any row inserted since the first request shifts the window rather than appending to it. The correct shape is a second, much smaller operation that addresses that one shipment and continues its child connection with the cursor that connection itself produced: ```graphql query MoreScans($id: ID!, $after: String!) { shipment(id: $id) { scanEvents(first: 10, after: $after) { edges { cursor node { scannedAt facilityCode } } pageInfo { hasNextPage endCursor } } } } ``` How the client addresses a single parent is a separate matter — a domain root field taking the parent's key, or the generic refetch field the Relay server convention defines for globally identified objects. Either way the point stands: the unit of continuation is the parent, not the page. ## Cursors are scoped to the connection that produced them A cursor is an opaque token that means "this position in *this* ordered result set". Shipment AWB-8814's `endCursor` positions you inside AWB-8814's scan events. Handing it to shipment AWB-9207's `scanEvents(after:)` is not a smaller mistake than passing a random string; depending on how the server encodes cursors it will either error, return an empty page, or — worst — decode into a plausible sort key and return a page that looks fine and is silently wrong. Client state for nested lists therefore has to be keyed by parent: a map from parent identity to that list's cursor and exhaustion flag, never a single "next cursor" for the field. ## The outer pageInfo says nothing about the inner lists `shipments.pageInfo.hasNextPage` answers one question: are there more **shipments** after this page. It is not affected by, and tells you nothing about, whether any shipment has more scan events. Conversely a nested `hasNextPage: true` does not mean the outer page is incomplete. Two independent paging states exist at the two levels, and a UI that wires one "load more" button to both will either stall or double-fetch. ## What a correct implementation looks like on the client Three pieces of state per rendered parent: the items so far, that parent's `endCursor`, and that parent's `hasNextPage`. "Load more" on one card issues the single-parent operation above and appends. When the outer page itself advances, the newly arrived shipments come with their own first inner page and their own cursors; nothing about the already-loaded shipments' inner state changes. ## Why servers should keep the inner page small Because continuation is cheap and per parent, the inner default does not have to be generous. A first inner page of ten scan events per shipment, with a per-card continuation, costs far less than an inner page of a hundred that most cards never scroll — and the saving is multiplied by the outer page size. The asymmetry is the practical reason to treat the two levels' page sizes differently rather than reusing one number. ## The trap that survives review Two mistakes tend to pass code review because both produce plausible-looking data. The first is storing one cursor per *field* instead of one per parent, which only misbehaves once a user expands a second card. The second is deriving "there is more" from `edges.length === first` instead of reading that connection's `hasNextPage`; that heuristic is wrong exactly when the underlying page happened to be full, which is the common case, and it costs an extra empty round trip every time a child list ends on a page boundary.
- What does the outer connection's hasNextPage tell you about the nested lists inside it?Nothing at all. It answers one question — are there more parents after this page — and is unaffected by whether any parent has more children. The two levels carry independent paging state, so a UI that drives one "load more" control from both will either stall when the outer page ends or refetch parents when a child list is continued.
- Why not simply re-run the outer page with a larger inner first instead?Because it costs the whole outer page to extend one child list, re-sends rows the client already has, and is not a resume point: a larger `first` re-slices from the start of that parent's ordering, so rows inserted since the first request shift the window rather than appending to it. A single-parent operation with `after` continues exactly where the client stopped.
- Is checking edges.length against the requested first a safe substitute for reading hasNextPage?No. It is wrong precisely when the page came back full, which is the common case, so the client makes an extra request that returns an empty page every time a child list ends on a page boundary. It is also wrong in the other direction on servers that may return fewer edges than requested. Read the connection's own hasNextPage.
saying these in an interview costs you the question
- Treats pageInfo as one object per response
- Reuses one parent's cursor on another parent
- Refetches the outer page to extend a child list
- Thinks outer hasNextPage covers nested lists
- Stores one next cursor per field, not per parent
- Infers more pages from edge count alone