skip to content

Cursor-Based Pagination

Relay-style connections with edges and cursors map onto keyset scrolling, supporting forward and backward paging. Interviewers ask why cursors beat offsets at scale, which is the same argument as keyset pagination anywhere.

part ofSpring for GraphQLoverview, primer and where to startread it →
on this pageshow

questions

5

What is Relay-style cursor-based pagination in Spring for GraphQL, and what do the Connection, Edge, and PageInfo types represent?

level: juniorimportance: must knowfreq 42%

answer

  1. Connection -> edges + pageInfo
  2. Edge = node + cursor
  3. first/after forward, last/before backward
  4. opaque cursor, don't parse
  5. return Window, Spring builds edges

basics

~20 s

It is a standard way to page a list using opaque cursors instead of page numbers. A Connection wraps Edges; each Edge has a node (the item) plus its cursor; PageInfo carries hasNextPage/hasPreviousPage and start/end cursors.

solid answer

~40 s

Relay cursor connections are a GraphQL convention for stable pagination. A field returns a `XxxConnection` type containing `edges: [XxxEdge]` and `pageInfo: PageInfo`. Each `Edge` has `node` (the actual entity) and `cursor` (an opaque string pointing at that item's position). `PageInfo` exposes `hasNextPage`, `hasPreviousPage`, `startCursor`, and `endCursor`. Clients page forward with `first`/`after` and backward with `last`/`before`, passing back a cursor rather than an offset. Cursors are opaque — clients must not parse them. Spring for GraphQL supports this out of the box: you return a Spring Data `Window<T>`, and Spring auto-wraps it into the Connection/Edge/PageInfo shape and generates the cursors for you. The advantage over offset paging is stability: inserts/deletes elsewhere in the list don't shift or duplicate results.

code

java · 22 lines
java
# GraphQL schema (SDL) — Relay connection shape

type Query {
  books(first: Int, after: String, last: Int, before: String): BookConnection
}

type BookConnection {
  edges: [BookEdge]
  pageInfo: PageInfo
}

type BookEdge {
  node: Book
  cursor: String
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

go deeper

for a junior

Know the vocabulary: Connection wraps Edges, Edge has node+cursor, PageInfo has hasNextPage and cursors; first/after vs last/before.

for a middle

Know that Spring auto-generates edges/pageInfo when you return a Window, and cursors are opaque base64.

for a senior

Explain why cursor paging beats offset for stability/performance and when totalCount is costly.

for a principal

Frame connections as a contract decision — opacity enables server-side evolution of the position encoding without client breakage.

**The problem cursor pagination solves.** Classic offset pagination (`LIMIT 20 OFFSET 40`) is fragile: if a row is inserted or deleted before your current position between requests, items shift and you either skip or re-see rows. Cursor (a.k.a. keyset) pagination instead remembers *where you were* by the value of the sort key(s), so paging is stable under concurrent writes and is faster at deep offsets because the database can seek rather than count-and-skip. **The Relay Cursor Connections spec.** Relay standardized a wire shape that GraphQL servers commonly implement: - **Connection** — the return type of a paginated field, e.g. `BookConnection`. Contains `edges` and `pageInfo` (and optionally `totalCount`). - **Edge** — one entry: `{ node, cursor }`. `node` is the real domain object; `cursor` is an **opaque** base64 string that encodes the item's position (its keyset values or an offset). Opaque means the client treats it as a black box and just echoes it back. - **PageInfo** — `{ hasNextPage, hasPreviousPage, startCursor, endCursor }`. Lets a client know whether to keep paging and which cursor to send next. - **Pagination arguments** — `first: Int` + `after: String` for forward paging; `last: Int` + `before: String` for backward paging. **How Spring for GraphQL supports it.** In `spring-graphql` with Spring Data on the classpath, you don't hand-build edges. You: 1. Declare Relay types in your schema (the `...Connection`/`...Edge`/`PageInfo` types, named by convention ending in `Connection`). 2. Return a Spring Data `org.springframework.data.domain.Window<T>` (or `Slice`/`Page`) from your `@SchemaMapping`/`@QueryMapping` method. 3. Spring's `ConnectionFieldTypeVisitor` decorates the field's `DataFetcher`, detects the container via a registered `ConnectionAdapter`, and produces the `edges` (with per-item cursors) and `pageInfo` automatically. A `CursorStrategy` (default `ScrollPositionCursorStrategy`) encodes/decodes each cursor. **Key terms defined.** - *Cursor*: opaque token identifying a position in the ordered result. In Spring it encodes a Spring Data `ScrollPosition` (keyset values or an offset), base64-encoded. - *Keyset*: the tuple of sort-key values (e.g. `(createdAt, id)`) used to seek past the last-seen row. - *Window<T>*: Spring Data's scroll result — a list of items plus the `ScrollPosition` for each and `hasNext()`. **When to use.** Use cursor connections for large, frequently-changing, or infinitely-scrolled lists, and whenever a client needs stable forward/backward navigation. Offset paging is fine for small, stable, jump-to-page-N admin tables. **Gotchas.** Cursors are opaque — never let a client construct or interpret them. `totalCount` is optional and, with keyset paging, often expensive (a separate `COUNT`), so many APIs omit it. The sort order must be deterministic (unique) or cursors become ambiguous.

  • Why are cursors opaque instead of, say, the raw id?
    So the server can change its internal position encoding (keyset values, offset, extra tie-breakers) without breaking clients, and so clients can't forge positions. Clients only ever echo a cursor back.
  • Why is cursor paging more stable than offset paging?
    It anchors on the value of the last-seen row (a keyset seek) rather than counting N rows in. Inserts/deletes elsewhere don't shift the anchor, so you don't skip or duplicate rows.

saying these in an interview costs you the question

  • Thinking a cursor is a page number or numeric offset the client increments
  • Claiming you must manually build edges and pageInfo in the resolver
  • Saying PageInfo is optional/omittable for Relay connections
  • Believing totalCount is always present in a Connection

context

open as a page

How does Spring for GraphQL turn the incoming first/after/last/before arguments into a controller method parameter? Explain ScrollSubrange and Subrange.

level: middleimportance: should knowfreq 33%

basics

~20 s

You declare a ScrollSubrange parameter on the controller method. Spring parses first/after (or last/before) into it, exposing the decoded position (a Spring Data ScrollPosition), the count, and a forward flag you pass to your query.

open as a page

You return a Spring Data Window<Book> from a resolver, yet the client receives edges, cursors, and pageInfo. What machinery produces that? Explain ConnectionFieldTypeVisitor and ConnectionAdapter.

level: seniorimportance: should knowfreq 28%

basics

~20 s

A GraphQL type visitor, ConnectionFieldTypeVisitor, wraps the field's DataFetcher. When the resolver returns a Window (detected by a ConnectionAdapter), the visitor converts it into edges (node + cursor per item) and PageInfo, encoding each cursor from the item's ScrollPosition.

open as a page

Compare KeysetScrollPosition and OffsetScrollPosition for GraphQL cursor paging, explain forward vs backward paging, and why the sort must be unique and stable.

level: seniorimportance: should knowfreq 26%

basics

~20 s

Offset positions seek by row count (LIMIT/OFFSET) — simple but shifts under writes and is slow deep in the list. Keyset positions seek by the last row's sort-key values — stable and fast, but the sort must be unique or rows get skipped or duplicated.

open as a page

How is the whole cursor-connection pipeline wired in Spring for GraphQL — the beans Boot auto-configures, how to customize the cursor encoding or page-size limits, and how to support a non-Spring-Data container?

level: principalimportance: nice to knowfreq 15%

basics

~20 s

With Spring Data present, Boot auto-registers a ScrollPositionCursorStrategy, Window/Slice ConnectionAdapters, and a customizer that adds ConnectionFieldTypeVisitor. You customize by supplying your own CursorStrategy/CursorEncoder or ConnectionAdapter beans; for a foreign container, implement and register a custom ConnectionAdapter.

open as a page