Skip to main content

Pagination

Every list in this API is a Relay cursor connection. There are no offset arguments, no page numbers, and no unbounded lists.

{
campaigns(first: 20) {
totalCount
pageInfo { hasNextPage endCursor }
edges {
cursor
node { id name status spendToDate { amountMinor currency } }
}
}
}

The four arguments

ArgumentDirectionPairs with
firstforward, from the startafter
afterforwardfirst
lastbackward, from the endbefore
beforebackwardlast

Three rules the platform sets, and the Ads module enforces exactly:

  1. A page size is mandatory. first or last must be present. A connection never guesses, because a query that silently returned "the first twenty" would return something different the day the default changed.
  2. The ceiling is 50. Ask for more and the request is refused, not truncated — a silently shortened page is how a client ends up believing it has seen everything.
  3. first and last together are refused. They describe two different pages and the server will not pick one for you.

The People module holds the first two, and answers the third with an unclassified fault rather than a named refusal. It also does not enforce two things you would reasonably expect it to: a page size above zero, and a cursor it actually issued. Those differences bite quietly, so the refusal table below is worth reading before you write a paging loop against employees.

Cursors are keyset, and they are opaque

A cursor names a row, not a position. It encodes the sort keys of the row it points at, so a page taken with after: is stable when rows are inserted or deleted elsewhere in the list — the thing offset pagination cannot do. Paging deep costs the same as paging shallow.

Because a cursor encodes sort keys, it belongs to the connection that issued it — and to the exact arguments that call carried. A cursor from campaigns means nothing to employees, and a cursor from campaigns(accountId: …) means nothing to an unfiltered campaigns.

Nothing checks that for you

Both modules share one cursor grammar — deliberately, arbitrated by test vectors — which is exactly why a cursor from the wrong connection decodes instead of failing. Replay one and the server reads the sort keys inside it, applies them as a position in whatever set you did ask for, and hands back a plausible page.

Checked against a running router, across modules and across filters. campaigns(first: 2) returns Advantage Plus Shopping and Creator Test; hand the same call a cursor taken from employees and it returns Creator Test and Glow Serum - Search Brand — one campaign silently skipped, because the employee's name sorts after the first campaign's. The reverse works just as badly, and a cursor from campaigns(accountId: …) replayed on an unfiltered campaigns skips ahead the same way.

No error, no empty page, no signal of any kind. The only cursor that is safe is one that came back from the identical field with the identical arguments, in the loop you are continuing.

Never parse a cursor. Its contents are an implementation detail of the module that produced it, they differ between modules today, and they are free to change without a schema change. The only supported operations are: pass it back as after or before, or throw it away.

Why cursors from different modules look different

The encoding is one convention across every language the fleet is written in, fixed by a shared set of test vectors that both stacks assert against byte for byte. Within that convention, a cursor may carry an optional page block — where the row sits and how big the underlying set is. The Ads module populates it; the People module emits it empty. Same grammar, different content, and neither is something to depend on.

Reading pageInfo

FieldMeaning
hasNextPageMore rows exist after this page. Page forward with first and endCursor
hasPreviousPageMore rows exist before this page
startCursorThe first edge's cursor, or null when the page is empty
endCursorThe last edge's cursor, or null when the page is empty

totalCount is the number of rows behind the whole connection, ignoring the page window. It is a real count, not an estimate.

The loop, then, is: request first: n; if pageInfo.hasNextPage, request the same field again with first: n, after: pageInfo.endCursor; stop when it is false.

Why Ads connections name their page info differently

Ads connections return AdsPageInfo; People and Sales connections return PageInfo. The four fields above are identical in all three.

The reason is not cosmetic. People and Sales are built on a framework that publishes its own six-field PageInfo, including two list fields the Ads module has no way to produce. A single shared type would have composed to the union of both shapes — which means an Ads connection would have advertised two non-null fields that fail at query time, after passing every build-time check. It was verified that composition accepts exactly that supergraph.

So the name differs, visibly, instead of the behaviour differing, invisibly. People and Sales do share one PageInfo with each other, because the same framework emits the same six fields in both and that is checked on every build. If Ads ever grows the two missing fields, the shared name comes back additively and the module-specific one is deprecated with a date, like everything else here.

When pagination is refused

Every row below was checked against the running modules. Three of the seven mistakes are refused identically by every module; three of the rest are refused by the framework underneath People and Sales, before any code of ours runs, so they carry its vocabulary rather than the platform's; and one is refused everywhere but by the wrong name.

You sendAds — campaigns, adAccountsPeople — employees, leaveRequests · Sales — orders, products
first: 0refused, PAGINATION_PAGE_SIZE_INVALIDrefused, PAGINATION_PAGE_SIZE_INVALID
A cursor it cannot decoderefused, PAGINATION_INVALID_CURSORrefused, PAGINATION_INVALID_CURSOR
first and last togetherrefused, PAGINATION_BOUNDARIES_AMBIGUOUSrefused, PAGINATION_BOUNDARIES_AMBIGUOUS
Neither first nor lastrefused, PAGINATION_BOUNDARIES_REQUIREDrefused, HC0052
A page size above 50refused, PAGINATION_PAGE_SIZE_EXCEEDEDrefused, HC0051
first: -1refused, PAGINATION_PAGE_SIZE_INVALIDrefused, HC0079
A cursor truncated mid-wayrefused, INTERNAL_SERVER_ERRORrefused, UNEXPECTED_ERROR
A cursor from another connection, or another filteraccepted — read as a positionaccepted — read as a position

Three things follow for a client.

A refusal is never an empty page. first: 0 and a corrupted cursor are bugs in the caller, and both used to come back from People as a well-formed empty page — which is exactly what a paging loop reads as "there is nothing more". They are named refusals now, everywhere. Check the page size before you send it, and never construct, persist or reuse a cursor outside the loop that received it.

A well-formed cursor from somewhere else is still accepted. The last row is the one case no module refuses: a cursor is a position, not a ticket, so one taken from a different connection or the same connection under a different filter is read as a position and answers something plausible. Keep each paging loop's cursors inside that loop.

Branch on the code you actually receive. Every refusal above carries one, at errors[].extensions.code — see Errors for the envelope. The three framework-named rows are a known divergence, recorded rather than hidden: the platform names win if it closes, and the change is additive for anything already switching on the current strings. The truncated-cursor row is a known gap rather than a design: the cursor is well-formed enough to get past the page-boundary checks and then fails when it is read as a position, so what comes back is the generic fallback code and not PAGINATION_INVALID_CURSOR. Treat any of these as "my cursor is bad, start the loop again" rather than switching on the exact string.