A post has collected 5000 comments. You cannot send them in one response: the server, the network and the browser would choke. People treat it like a book: they read page by page, and a bookmark shows where they stopped. That is pagination, and the bookmark is called a cursor. There is a second task: while you read, new comments appear under the post. Asking the server every five seconds (polling) is tiring; it is better to hear a doorbell. That is a subscription. In this lesson you build a feed of comments with a "Load more" button and with new comments arriving in real time.

Offset or cursor

The simplest way is offset: "skip 10, give me the next 10" (skip and take, as in the Prisma topic). It lets you jump straight to page 50, but it has two drawbacks. Deep pages are slow: the database still walks over the skipped rows. And the pages "slide" if something was added while you read: items are duplicated or lost.

A cursor says it differently: "give me 10 records after this bookmark". The query uses an index, does not depend on depth and is not broken by new records. The price is that you cannot jump to page 50: only "next".

Offset shows one comment twice after an insert, the cursor continues from the right place
Offset shows one comment twice after an insert, the cursor continues from the right place

Relay connection: edges, node, cursor, pageInfo

GraphQL has a common agreement for cursor pagination — the connection from the Relay specification. The field returns not a list but an object with three parts:

  • edges is a list of "envelopes"; each holds a node (the comment itself) and a cursor (a bookmark on exactly that one). The envelope exists to keep data about the relationship, not about the object.
  • pageInfo is the navigation: hasNextPage ("is there more?") and endCursor ("a bookmark on the last item").
  • totalCount is a common addition to the specification: how many items there are in total (in our schema the field is required).

The specification adds hasPreviousPage and startCursor to pageInfo (for going backwards); we do not need them. The Post.comments field from the earlier lessons now returns a connection (our learning project has no outside clients; in a live API you must not break a schema like this):

graphql
type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

type CommentEdge {
  cursor: String!
  node: Comment!
}

type CommentConnection {
  edges: [CommentEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type Post {
  # ...the other fields as before
  comments(first: Int = 10, after: String): CommentConnection!
}
Connection: edges with cursor and node, pageInfo with endCursor, the next request goes with after
Connection: edges with cursor and node, pageInfo with endCursor, the next request goes with after

Server: a cursor in Prisma

For the client the cursor is an opaque string: it must not parse it or guess its content. We encode the comment's id in base64url. Three rules: the order rests on a unique unchanging field (id), the page size is capped, and we read one record more to learn hasNextPage. The Prisma query is the same as in the Prisma topic: cursor, skip: 1, take.

ts
// src/resolvers/post.ts
const encodeCursor = (id: number) =>
  Buffer.from(String(id)).toString('base64url');