skip to content

How can a server keep the Relay connection shape while paging by offset underneath?

level: middleimportance: nice to knowfreq 22%

answer

  1. The client never reads it
  2. Opaque means the server picks the contents
  3. A number can hide in there
  4. Decode, skip, take one extra
  5. Encoding is not signing

basics

~20 s

Encode the position in the opaque cursor. The server emits base64 of "offset:42", decodes an incoming after cursor back to a number, and slices with skip and take. The cursor anchors a position, not a row.

solid answer

~50 s

The cursor connections convention requires `cursor` to serialize as a `String` and tells clients to treat it as opaque — it never says what is inside. So a server whose store only offers skip-and-take can emit `base64("offset:42")`, decode an incoming `after` back to 42, fetch `first + 1` rows from position 43 so `hasNextPage` needs no count query, and return edges whose cursors are the successive positions. Every convention-aware client works unchanged. The price is that the cursor is now a position, not an anchor: it inherits every offset behaviour, so a cursor held across concurrent writes points at a different animal than it did. The payoff is that opacity keeps the payload out of your contract — you can migrate the same field to a real keyset cursor later with no SDL change and no breaking change.

code

pseudocode · 18 lines
pseudocode
resolve herd.animals(first, after):
    start    = (after == null) ? 0 : decodeOffset(after) + 1
    pageSize = clamp(first ?? 25, 1, 100)

    rows = store.list(order = [birthDate ASC, id ASC],
                      skip  = start,
                      take  = pageSize + 1)

    hasNext = rows.size > pageSize
    page    = rows.take(pageSize)
    edges   = page.mapIndexed { i, row -> Edge(cursor = encodeOffset(start + i), node = row) }

    return Connection(
        edges,
        PageInfo(hasNextPage     = hasNext,
                 hasPreviousPage = start > 0,
                 startCursor     = edges.firstOrNull()?.cursor,
                 endCursor       = edges.lastOrNull()?.cursor))

go deeper

for a junior

Know that a connection's cursor is an opaque string: the client stores it and passes it back unchanged, and never parses or constructs one. What the server puts inside is invisible to the caller.

for a middle

Be ready to walk the decode-slice-encode loop, explain why fetching one extra row fills hasNextPage without a count, and state clearly what the cursor no longer guarantees once it holds a position.

for a senior

Show judgement about when this bridge is acceptable — slow-changing data, back-office tables, a source that only offers page numbers — and name the guardrails: clamp the page size, error on a bad cursor, never publish the encoding.

for a principal

Own the published guarantee. Decide whether the schema documents page stability, and treat cursor opacity as a deliberately preserved migration path rather than an accident, so a store change never becomes a client change.

## Why anyone would do this Two forces pull in opposite directions. On the client side, the connection shape is what the ecosystem expects: normalized caches know how to merge `edges` across pages, list and infinite-scroll components are generated against it, and a reviewer will ask why your new field is shaped differently from every other paged field in the schema. On the server side, the thing behind the field sometimes cannot do keyset paging at all — a reporting view, a search index that takes a page number, a stored procedure, or an upstream API whose only paging control is `page=3`. Packing the offset inside the cursor lets you satisfy both. The schema publishes a normal connection; the resolver pages by position underneath. ## What "opaque" licenses The cursor connections convention requires the `cursor` field to serialize as a `String` and instructs clients to treat it as an opaque token: pass it back unchanged, never parse it, never construct one. It says nothing about what is inside. Base64 is a convention adopted precisely to make the contents look unreadable and discourage clients from poking at them — it is encoding, not signing, and it protects nothing. So a positional payload is as legal as a sort key. `base64("offset:42")` is a valid cursor. ## The mechanics The whole implementation is a decode, a slice and an encode: ``` resolve herd.animals(first, after): start = (after == null) ? 0 : decodeOffset(after) + 1 pageSize = clamp(first ?? 25, 1, 100) rows = store.list(order = [birthDate ASC, id ASC], skip = start, take = pageSize + 1) // one extra hasNext = rows.size > pageSize page = rows.take(pageSize) edges = page.mapIndexed { i, row -> Edge(cursor = encodeOffset(start + i), node = row) } return Connection(edges, PageInfo( hasNextPage = hasNext, hasPreviousPage = start > 0, startCursor = edges.firstOrNull()?.cursor, endCursor = edges.lastOrNull()?.cursor)) ``` Two details are worth calling out. Fetching `pageSize + 1` rows and discarding the extra gives you `hasNextPage` without a second, full-predicate count — on a pedigree browser holding a 340 ms p99 budget for the field, that one trick is often the difference between meeting the budget and not. And `hasPreviousPage` is genuinely cheap here, because position zero is knowable: `start > 0` is the honest answer, which is more than a keyset implementation can usually say without extra work. ## What you keep The valuable thing you keep is **the freedom to change your mind**. Because the cursor is opaque, its contents are not part of your published contract. You can migrate this field from offsets to a real keyset cursor later with no SDL change, no client change and no breaking-change entry in a registry — only a decoder that recognises both payload shapes for as long as old cursors might still be in flight. Shipping an opaque cursor over an offset is often exactly that: a bridge, taken deliberately. ## What you give up You give up anchor semantics, and you should say so out loud rather than let a client discover it. A keyset cursor means "resume after the animal whose birth date is X and whose id is Y". An offset cursor means "resume at position 42 of whatever this ordering produces right now". Everything that is true of offset paging is therefore true of this connection, dressed in connection clothes: an insert before the window shifts every later position, so a row can appear on two consecutive pages or fall between them. A cursor stored on the client and replayed an hour later points somewhere arbitrary. And if the ordering is not total, position 42 need not even be the same animal across two identical requests. ## Guardrails * **Clamp the page size** in the resolver. `first` is just an `Int`; nothing else will bound it. * **Reject a cursor you cannot decode** as an error rather than silently falling back to offset zero — a silent fallback shows up as a user mysteriously restarting at the top of the list. * **Never document the encoding.** The moment one client base64-decodes your cursor and does arithmetic on it, the payload has become a contract and the migration path above is gone. * **Be explicit in the field's description** about the stability you actually offer, so that "duplicate rows while paging" arrives as a documented property rather than a bug report. ## When it is the right call Small or slow-changing collections, admin and back-office tables, a source that literally only offers page numbers, and any field you intend to migrate to keyset paging once the store can support it. It is the wrong call for a high-write feed with long-lived cursors, where the drift is constant and visible.

  • Does the cursor connections convention allow a cursor to encode a position rather than a row key?
    It does not say, which is the point. The convention requires the cursor to serialize as a `String` and instructs clients to treat it as opaque and pass it back unchanged; the contents are the server's business. What the convention implies is only that a cursor names a place in the ordering — an offset satisfies that literally, just not stably.
  • If the cursor hides an offset, what can you change later without a breaking schema change?
    The entire payload. Because no client parses it, you can switch from an encoded offset to a keyset cursor carrying the sort key and a tie-breaker without touching the SDL. The only care needed is cursors already in flight: give the payload a version discriminator, or decode both shapes for a grace period.
  • How do you fill hasNextPage without running a second count query?
    Ask the store for one row more than the page size and drop the extra; its presence is the answer. That matters under a tight latency budget — a 340 ms p99 on the list field — because a full-predicate count over the same criteria roughly doubles the work for a single boolean.

saying these in an interview costs you the question

  • Says the connections convention requires a sort key in the cursor
  • Documents the cursor encoding so clients can build their own
  • Treats an offset cursor as a stable anchor to a row
  • Calls base64 cursors a security measure
  • Runs a full count on every page to fill hasNextPage
  • Silently falls back to offset zero on an undecodable cursor

context