A client paginates with endCursor and the last page never arrives, because hasNextPage is
computed from whether the slice was full. A page that happens to end exactly on a boundary reports
more rows, the client asks again, and gets an empty page with the same flag. The loop runs until
somebody kills it.
What you get
You will end up with a connection whose edges carry cursors, and page flags derived from the cursors rather than from the slice length. This is for you if you are adding pagination to a GraphQL field and want clients to be able to walk it safely.
Short answer
Return a connection of edges, each carrying a node and an opaque cursor, plus a pageInfo object
with hasNextPage, hasPreviousPage, startCursor and endCursor. Apply after and before
first, then first or last, and read the page flags from what the cursors left behind. Refuse
first and last together rather than picking one.
You will need
A GraphQL schema with a list field, and Node 22 or later. The shape is set by the Relay Cursor Connections specification, and the argument rules in its pagination algorithm section are the part most implementations get partly wrong.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A simple limit and offset list field | An internal schema with small, stable lists and no Relay client | Rows that shift under a paging client, and no convention for a tool to rely on | A Relay or Apollo client expects connections |
| Connection fields you write | You want the algorithm visible and the cursors under your control | The algorithm to implement and to test, including the flags nobody checks | A schema builder in use already generates them |
| Pothos relay plugin | A code-first schema in TypeScript, where types come from the builder | A plugin and its conventions, in return for the boilerplate | The schema is defined as SDL rather than in code |
| graphql-relay-js helpers | You want the reference implementation of the algorithm and nothing else | Array helpers that assume the whole list is in memory | Rows come from a database you must page in SQL |
The decision is whether the cursor means something to your storage. A helper that slices an array
is correct and it loads every row first, which is fine for a list of 200 and wrong for a table with
a million. Writing the connection yourself lets the cursor carry a sort key that becomes a WHERE
clause, and it puts the page flags in your hands, where they can be tested. Start with the helper, and
replace it the first time a list grows past what you want in memory.
Apply the cursors before the counts
The order of the four arguments is the algorithm, and reversing it produces flags that look right and are not.
const afterCursors = window.length
let sliced = window
if (first != null) sliced = sliced.slice(0, first)
if (last != null) sliced = sliced.slice(Math.max(0, sliced.length - last))
afterCursors is the count that matters. It is how many rows remain once after and before have
been applied, and before first trims the page. Comparing it against first answers whether a
next page exists. Comparing the returned page against the requested size does not, which is the bug
in the opening paragraph.
Set the flags the specification actually asks for
hasPreviousPage is false on a forward page, and that is correct rather than lazy.
hasNextPage: first != null ? afterCursors > first : false,
hasPreviousPage: last != null ? afterCursors > last : false,
The specification permits false when the server was not asked to look in that direction, because counting backwards can cost a second query. Clients are written against that rule. A server that computes it anyway is doing extra work for a field nobody trusts, and one that returns an arbitrary value breaks a client that does.
Cursors are opaque, and making them opaque is not decoration. A base64 value stops a client constructing one from an offset, which means you can change the sort key later without breaking anyone who had reverse engineered the old one. Encode a version marker into the cursor as well, so a cursor issued by an older deployment can be recognized and refused rather than misread.
Check it worked
Page through seven rows and watch the flags.
node demo.mjs
first 3 ids 1,2,3 next true prev false
first 3 after page 1 ids 4,5,6 next true prev false
first 3 from the tail ids 6,7 next false prev false
last 2 ids 6,7 next false prev true
first 3 and last 2 error: pass first or last, never both
after an unknown cursor error: unknown cursor: bWV0ZXI6OTk5
cursor for meter 1: bWV0ZXI6MQ
Row three is the one to check in your own implementation. The page came back short, with two rows
against a requested three, and hasNextPage is false. An implementation that compares the page
length with the request gets this right by accident and gets the exact-boundary case wrong.
The last two rows matter as much. Both argument errors are refused rather than resolved into
something plausible.2 A connection that silently ignores last when first is also present will
return a page the client did not ask for, and the client has no way to tell.
node --test connection.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 119.767704
When it goes wrong
A client loops forever. hasNextPage is derived from the slice length. Derive it from the count
remaining after the cursors are applied, and add a test for a page that ends on the boundary.
A cursor from yesterday returns nothing. The cursor encodes an offset and rows were inserted. Encode the sort key instead, and the cursor keeps meaning after a write.
Every query loads the whole table. The connection helper slices an array. Push after into the
query as a WHERE clause, and fetch one row more than asked so the flag needs no second query. That
extra row is the cheapest form of hasNextPage there is, and it is what most production
implementations end up doing.
totalCount makes the field slow. A count over a large filtered table is a second expensive query.
Make it optional in the schema, and let clients pay for it only when they ask. A field that is
cheap on a list of 200 rows is the reason a dashboard takes nine seconds two years later.
When not to do this
Do not add connections to a schema no Relay-style client consumes. The shape costs two extra types
per list and a layer of edges and node in every query. A plain list with a limit is easier to
read, and moving to connections later is an additive change: add the new field, leave the old one,
retire it when usage drops.
Do not expose a cursor that decodes to a database offset.3 Clients will read it, build their own, and then depend on a detail you meant to keep private. The next index change becomes a breaking one.
Do not compute both page flags on every request because it feels more correct. The second count is a real query against a real table, and the specification exists so you do not have to run it.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
The
PageInfosection says that on a backward page the server must report whether prior edges exist. On a forward page, it says, “the client may return true if edges prior toafterexist, if it can do so efficiently.” The client returns nothing. It asked. The word is a slip, and it has stood in the published text. The section’s own anchor readssec-undefined.PageInfo, a second slip in the paperwork that also still stands. ↩︎ Back to text -
The specification stops short of forbidding the pair. Its pagination algorithm calls passing both
firstandlast“strongly discouraged” on the grounds that it is likely to lead to confusing queries and results. ThePageInfosection then defines both flags for that case anyway, and adds that their meaning “becomes unclear” once it has. A document that discourages a thing and then specifies it in full expects to be ignored on the point. Refusing the pair is stricter than the document, and it takes one line where the document took two notes. ↩︎ Back to text -
The reference implementation is less shy. The array helpers in graphql-relay-js make a cursor by writing
arrayconnection:in front of the array index and base64 encoding the result, soYXJyYXljb25uZWN0aW9uOjI=is index 2 wearing a label. Its README describes whatoffsetToCursorreturns as an opaque cursor, which it is, in the sense that base64 is a lock. ↩︎ Back to text