Pagination and the connection pattern

Offset versus cursor pagination, building a Relay connection, pageInfo semantics, and why stable ordering is not optional.

The connection shape

type BookConnection {
  edges: [BookEdge!]!
  nodes: [Book!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type BookEdge {
  cursor: String!
  node: Book!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
query Page($first: Int!, $after: String) {
  books(first: $first, after: $after) {
    nodes { id title }
    pageInfo { hasNextPage endCursor }
  }
}
  • nodes is the convenient shortcut; edges exists because an edge can carry data about the relationship (a role, a position, a joined-at timestamp).
  • The cursor is opaque to clients. It usually encodes the sort key plus the id, base64-encoded so nobody parses it.
  • Only ask for what you use: computing totalCount is a full count query on a large table.
⚠️
A non-unique sort key makes cursor pagination silently drop or repeat rows. Always tie-break on a unique column: order by published_at desc, id desc, and encode both values in the cursor.

Implementing a paginator

const encode = ({ createdAt, id }) =>
  Buffer.from(createdAt + "|" + id).toString("base64url");

const decode = (cursor) => {
  const [createdAt, id] = Buffer.from(cursor, "base64url").toString().split("|");
  return { createdAt, id };
};

async function books(_p, { first = 20, after }, ctx) {
  const limit = Math.min(first, 100);          // hard cap, always
  const where = after
    ? { OR: [
        { createdAt: { lt: decode(after).createdAt } },
        { createdAt: decode(after).createdAt, id: { lt: decode(after).id } },
      ] }
    : {};

  const rows = await ctx.db.book.findMany({
    where,
    orderBy: [{ createdAt: "desc" }, { id: "desc" }],
    take: limit + 1,                            // one extra row tells us hasNextPage
  });

  const hasNextPage = rows.length > limit;
  const nodes = hasNextPage ? rows.slice(0, limit) : rows;

  return {
    nodes,
    edges: nodes.map((node) => ({ cursor: encode(node), node })),
    pageInfo: {
      hasNextPage,
      hasPreviousPage: Boolean(after),
      startCursor: nodes.length ? encode(nodes[0]) : null,
      endCursor: nodes.length ? encode(nodes[nodes.length - 1]) : null,
    },
  };
}
ApproachStable under insertsJump to pageCost
Offset / limitNo - rows shiftYesOFFSET scans skipped rows
Cursor / keysetYesNoIndex seek, constant per page
pageInfo.totalCountn/an/aExtra count query
Unbounded [Book]n/an/aThe outage everyone eventually has

Returning a bare list without a limit is the most common GraphQL production incident: one client asks for everything and the server tries to serialise a million nodes. Cap the page size in the resolver, not in the schema documentation.

FAQ

Do I need the full Relay connection shape?
Only if your clients use Relay-style caching or a library that expects it. nodes plus pageInfo is often enough. What is not optional is a bounded page size and a stable sort order.
How do I implement backward pagination?
Accept last and before, reverse the sort order in the query, fetch last + 1, then reverse the results before building the connection so the client always receives ascending order.

Queries, mutations and resolvers DataLoader and solving the N+1 problem

Last refreshed 2026-09-18.