Never return an unbounded list. Page results with simple offset/limit, then graduate to the cursor-based Connections pattern that powers infinite scroll at scale.
Why: a field like books: [Book!]! that returns everything will eventually return thousands of rows and melt the server. Every list field that can grow needs pagination — a way to ask for a slice. There are two common styles: offset and cursor.
offset/limit ─ "skip 40, take 20" simple, but shifts if data changes
cursor ─ "20 items after THIS one" stable, the standard at scaleWhy: the simplest approach takes limit and offset arguments and slices the data. It is easy to build and fine for small, stable datasets. The downside: if rows are inserted while paging, items shift between pages — so it is wrong for fast-changing feeds.
type Query {
books(limit: Int = 20, offset: Int = 0): [Book!]!
}
# query { books(limit: 10, offset: 20) { title } }Why: the Relay Connections spec is the GraphQL standard for scalable pagination. A cursor is an opaque pointer to an item; you ask for "first: N after: cursor". It is stable under inserts and powers infinite scroll. The shape — edges, node, cursor, pageInfo — looks heavy but is conventional, so clients understand it automatically.
type BookConnection {
edges: [BookEdge!]!
pageInfo: PageInfo!
}
type BookEdge {
cursor: String!
node: Book!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Query {
books(first: Int!, after: String): BookConnection!
}Why: paging through a connection is a loop — request the first page, read pageInfo.endCursor, then pass it as "after" for the next. When hasNextPage is false you are done. This is exactly what an infinite-scroll list does under the hood.
query {
books(first: 10, after: "eyJpZCI6MjB9") {
edges {
cursor
node { title }
}
pageInfo {
hasNextPage
endCursor
}
}
}