skip to content

Why is a GraphQL connection cursor usually base64 text, and what does calling it opaque require of a client?

level: juniorimportance: must knowfreq 61%

answer

  1. The convention says one word about format
  2. The client's only legal move is echo
  3. Unstructured on purpose, so nobody parses it
  4. Encoding is not encryption or signing

basics

~20 s

Base64 is a convention, not a rule: the Relay cursor connections specification only requires a cursor to serialize as a String. Encoding it discourages parsing, so a client must store the cursor and echo it back unchanged.

solid answer

~40 s

The Relay cursor connections specification says an edge has a `cursor` field that serializes as a `String`, and nothing more — base64 is a widespread convention layered on top, not a specified requirement. Servers choose it because it makes the value visibly unstructured, survives JSON and query strings without escaping, and signals "do not parse me". Opacity is a two-way contract. The client's side: keep the cursor, send it back verbatim as `after` or `before`, never decode it, never construct one, never compare two of them to infer order. The server's side: it may change the encoding at any deploy, because nobody was allowed to depend on the format — and it must treat an incoming cursor as untrusted input, since base64 is encoding, not encryption and not signing.

code

graphql · 9 lines
graphql
type FilingEdge {
  cursor: String!
  node: Filing!
}

type FilingConnection {
  edges: [FilingEdge!]!
  pageInfo: PageInfo!
}

go deeper

for a junior

Be ready to say that a cursor is just a String you hand back untouched, and that base64 is a habit rather than a rule. Knowing the client-side contract — store it, echo it, never decode it — is the whole of what is expected here.

for a middle

Explain why the encoding exists at all: it buys the server freedom to change the payload, because no client was permitted to depend on the format. Be able to name what actually goes inside and why base64 makes the value transport-safe.

for a senior

Demonstrate that you treat an incoming cursor as untrusted input. An interviewer at this level listens for the sentence separating encoding from encryption, and for a versioning story that lets a stale cursor fail loudly rather than resume in the wrong place.

for a principal

Own the format as an interface decision. Argue what the organisation gains from opacity — the ability to change paging strategy without a client migration — and what it costs in cursor size, key management if you sign, and the support burden when clients cache cursors across deploys.

## What the specification actually says The Relay cursor connections specification — a convention its authors published for servers, **not** part of the GraphQL specification itself — says that an edge type must have a field named `cursor` returning a type that serializes as a `String`. That is essentially the whole of it. It does not say base64. It does not say what belongs inside. It does not say that two cursors from the same page must compare or sort in any particular way. Everything past *"serializes as a String"* is the server's choice. So the honest first sentence when an interviewer asks "why base64?" is: **because the specification left it open and the ecosystem converged on it**, not because a rule demands it. Being able to separate what is specified from what is merely universal is half of what the question is testing. ## Why the convention settled on base64 Three practical reasons, none of them about security. **It looks unstructured.** A cursor that reads `filing:90341` is an invitation. A front-end engineer on a deadline will split it on the colon, and six months later that split is load-bearing in someone's code and the server can no longer change its paging strategy. `eyJ2IjoxLCJpZCI6ImZfOTAzNDEifQ==` invites nothing. The encoding is a **social** device: it makes the value visibly not-for-you. **It is transport-safe.** A cursor travels as a JSON string in a response, then back as a variable in a request, and sometimes through a query string or a log line. Base64 — especially the URL-safe alphabet — has no quoting, escaping or normalization hazards on any of those paths. A raw timestamp with a `+` in its offset does. **It is cheap and symmetric.** Encode and decode are microseconds and need no shared state. The cost is size: base64 inflates the payload by about a third, and a connection carries one cursor **per edge**, so a 25-edge page of 120-byte cursors adds roughly 3 KB of response. ## What opacity obliges, on both sides Opacity is a two-way contract, and candidates usually remember only half of it. The **client's** half: keep the cursor you were handed, send it back byte-for-byte in `after` or `before`, and do nothing else with it. Do not decode it to show a row id. Do not synthesize one to jump somewhere. Do not compare two of them to decide which row came first — nothing promises the encoding is order-preserving, and often it deliberately is not. Storing a cursor is fine; *interpreting* it is not. The **server's** half: because nobody was permitted to depend on the format, you may change it at any deploy — add a field, switch the serialization, add a version tag. That freedom is the actual return on the encoding. And the flip side: **an incoming cursor is untrusted input.** Base64 is a public, reversible alphabet mapping. It is encoding, not encryption and not signing. Anyone can read a cursor, edit it and send it back, and the server must validate a decoded cursor exactly as carefully as it validates `first` or an ordering argument. ## The mistake that turns into a bug Because base64 *looks* like ciphertext, teams put things in cursors they would never put in a URL: an internal partition name, a tenant identifier, a raw sequential primary key. In a legal case-file graph, a cursor decoding to `{"v":1,"filedAt":"2026-04-11T09:14:07Z","id":"f_90341"}` quietly tells any caller that the store issues sequential filing ids and roughly how many filings exist. If the payload must stay secret, encrypt it. If it must be tamper-evident, sign it with a server-side key. If neither is worth the operational cost, put nothing sensitive in it. ## Versioning, the underrated payoff Because clients may store cursors — in a URL, in local state, in a saved report job — a cursor can outlive the encoding that produced it. A one-byte version tag inside the payload turns "this resumed at the wrong place" into "this cursor is from an older format", which the server can reject with a clear, actionable error instead of silently mis-resuming. That is only possible because opacity gave the server ownership of the bytes. ## What answers "is there more?" One last consequence of opacity: a client cannot look at `endCursor` and reason about whether the list is exhausted. The connection convention has a dedicated place for that — the boolean flags on page info — precisely because the cursor itself is unreadable. A candidate who proposes comparing the last cursor to some known value has not internalized what opaque means.

  • If cursors are opaque, how can a client save one and resume paging days later?
    Opaque means unparseable, not unstorable — a client may persist a cursor in a URL or in saved state and send it back later. The risk is that the server changed its encoding in between. That is why servers put a version tag inside the payload: an old cursor can then be rejected with a clear error instead of silently resuming at the wrong place.
  • Does base64-encoding a cursor make it safe to put a row's primary key inside?
    No. Base64 is a public, reversible alphabet mapping, so anyone can read the payload in seconds. A sequential primary key inside a cursor leaks that ids are sequential and roughly how many rows exist. If the contents must stay secret, encrypt them; if they must be tamper-evident, sign them; best of all, put nothing sensitive in a cursor.
  • Why shouldn't a client compare two cursors to work out which row comes first?
    Nothing promises the encoding preserves order — base64 of a JSON object certainly does not, and a server is free to change the payload whenever it likes. Ordering is the server's business, expressed through the connection's ordering argument. Whether more rows exist is answered by the page-info flags, which exist precisely because the cursor itself is unreadable.

A cursor is a cloakroom ticket. The number on it means something to the cloakroom and nothing to you; your only correct move is to hand back the same ticket.

saying these in an interview costs you the question

  • Base64 encoding makes a cursor secure or tamper-proof
  • The GraphQL specification defines the cursor format
  • Clients may decode a cursor to read the row id
  • A client can build a cursor to jump to any row
  • Cursors sort lexicographically, so comparing them gives order

context