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
| Argument | Direction | Pairs with |
|---|---|---|
first | forward, from the start | after |
after | forward | first |
last | backward, from the end | before |
before | backward | last |
Three rules the platform sets, and the Ads module enforces exactly:
- A page size is mandatory.
firstorlastmust be present. A connection never guesses, because a query that silently returned "the first twenty" would return something different the day the default changed. - 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.
firstandlasttogether 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.
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.
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
| Field | Meaning |
|---|---|
hasNextPage | More rows exist after this page. Page forward with first and endCursor |
hasPreviousPage | More rows exist before this page |
startCursor | The first edge's cursor, or null when the page is empty |
endCursor | The 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 send | Ads — campaigns, adAccounts | People — employees, leaveRequests · Sales — orders, products |
|---|---|---|
first: 0 | refused, PAGINATION_PAGE_SIZE_INVALID | refused, PAGINATION_PAGE_SIZE_INVALID |
| A cursor it cannot decode | refused, PAGINATION_INVALID_CURSOR | refused, PAGINATION_INVALID_CURSOR |
first and last together | refused, PAGINATION_BOUNDARIES_AMBIGUOUS | refused, PAGINATION_BOUNDARIES_AMBIGUOUS |
Neither first nor last | refused, PAGINATION_BOUNDARIES_REQUIRED | refused, HC0052 |
| A page size above 50 | refused, PAGINATION_PAGE_SIZE_EXCEEDED | refused, HC0051 |
first: -1 | refused, PAGINATION_PAGE_SIZE_INVALID | refused, HC0079 |
| A cursor truncated mid-way | refused, INTERNAL_SERVER_ERROR | refused, UNEXPECTED_ERROR |
| A cursor from another connection, or another filter | accepted — read as a position | accepted — 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.