Pagination
Pagination is essential for efficiently querying large datasets. Viaduct implements the Relay Connection specification, which provides a standardized, cursor-based pagination model for GraphQL.
Why Relay Connections?¶
- Cursor-based: More stable than offset pagination when data changes
- Bidirectional: Supports both forward and backward traversal
- Standardized: Well-understood pattern across the GraphQL ecosystem
- Rich metadata: Provides page information for UI pagination controls
Connections¶
A Connection represents a paginated list of items. It contains edges (the items with their cursors) and page information.
Connection Type¶
Use the @connection directive to define a connection type:
type UserConnection @connection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int # Optional additional fields
}
A type with @connection must:
- Have a name ending in
Connection - Have an
edgesfield with type[<EdgeType>!]!where the edge type has the@edgedirective - Have a
pageInfo: PageInfo!field
Edge Type¶
An Edge wraps a node and provides its cursor for pagination. Use the @edge directive:
A type with @edge must have:
- A
nodefield (any scalar, enum, object, interface, or union—not a list) - A
cursor: String!field
PageInfo¶
PageInfo is a built-in type that provides pagination metadata:
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Connection Field Arguments¶
Fields returning a connection type must include pagination arguments:
type Query {
# Forward pagination
users(first: Int, after: String): UserConnection!
# Backward pagination
recentUsers(last: Int, before: String): UserConnection!
# Bidirectional pagination
allUsers(first: Int, after: String, last: Int, before: String): UserConnection!
}
| Argument | Direction | Description |
|---|---|---|
first |
Forward | Number of items from the start |
after |
Forward | Cursor to start after (exclusive) |
last |
Backward | Number of items from the end |
before |
Backward | Cursor to end before (exclusive) |
Generated Interfaces¶
Viaduct generates Kotlin interfaces in viaduct.api.types that your GRTs implement:
Connection and Edge¶
Pagination Arguments¶
interface ConnectionArguments : Arguments
interface ForwardConnectionArguments : ConnectionArguments {
val first: Int?
val after: String?
}
interface BackwardConnectionArguments : ConnectionArguments {
val last: Int?
val before: String?
}
interface MultidirectionalConnectionArguments
: ForwardConnectionArguments, BackwardConnectionArguments
Generated argument types implement the appropriate interface based on which pagination arguments the field accepts.
Execution Context¶
Connection field resolvers receive a ConnectionFieldExecutionContext:
interface ConnectionFieldExecutionContext<
T : Object,
Q : Query,
A : ConnectionArguments,
O : Connection<*, *>,
> : FieldExecutionContext<T, Q, A, O>
This provides type-safe access to pagination arguments and ensures compatibility with builder utilities.
Building Connections¶
Connection GRT builders extend ConnectionBuilder, which provides utilities for common pagination scenarios.
From Edges (Native Cursors)¶
When your backend natively supports cursor-based pagination, use fromEdges():
@Resolver
class UsersResolver : QueryUsersResolver() {
override suspend fun resolve(ctx: Context): UserConnection {
val response = userService.getUsers(
cursor = ctx.arguments.after,
limit = ctx.arguments.first ?: 20
)
return UserConnection.Builder(ctx)
.fromEdges(
edges = response.users.map { user ->
UserEdge.Builder(ctx)
.node(ctx.nodeFor(user.id))
.cursor(user.cursor)
.build()
},
hasNextPage = response.hasMore,
hasPreviousPage = response.hasPrevious
)
.build()
}
}
From Slice (Offset/Limit)¶
When your backend uses offset/limit pagination, use fromSlice(). This automatically encodes offsets as cursors:
@Resolver
class UsersResolver : QueryUsersResolver() {
override suspend fun resolve(ctx: Context): UserConnection {
val (offset, limit) = ctx.arguments.toOffsetLimit()
val response = userService.getUsers(offset, limit + 1)
return UserConnection.Builder(ctx)
.fromSlice(
items = response.users,
hasNextPage = response.users.size > limit
) { user ->
ctx.nodeFor(user.id)
}
.totalCount(response.totalCount)
.build()
}
}
The fromSlice() method:
- Converts pagination arguments to offset/limit via
toOffsetLimit() - Builds edges with automatically encoded offset cursors
- Sets
pageInfowith correcthasNextPageandhasPreviousPagevalues
From List (Full Data)¶
When your backend returns the complete dataset and you want Viaduct to handle slicing:
@Resolver
class UsersResolver : QueryUsersResolver() {
override suspend fun resolve(ctx: Context): UserConnection {
val allUsers = userService.getAllUsers()
return UserConnection.Builder(ctx)
.fromList(allUsers) { user ->
ctx.nodeFor(user.id)
}
}
}
Converting Arguments to Offset/Limit¶
Use the toOffsetLimit() extension function to convert cursor-based arguments:
Valid argument combinations:
| Arguments | Result |
|---|---|
| None | First page with default size |
first |
First N items |
first, after |
N items after cursor |
after only |
Default page size after cursor |
last, before |
Last N items before cursor |
before only |
Default page size before cursor |
Validation:
firstandlastmust be > 0 if specifiedafterandbeforemust be valid, decodable cursors
Backward Pagination with Total Count¶
When only last is specified (without before), the total count is needed:
if (ctx.arguments.requiresTotalCountForOffsetLimit()) {
val totalCount = userService.getUserCount()
val (offset, limit) = ctx.arguments.toOffsetLimit(totalCount)
}
Cursors¶
Cursors are opaque strings that identify a position in a paginated list.
Offset Cursors¶
For offset/limit backends, Viaduct provides OffsetCursor:
@JvmInline
value class OffsetCursor(val value: String) {
fun toOffset(): Int
companion object {
fun fromOffset(offset: Int): OffsetCursor
}
}
Cursors are encoded as Base64 strings. The format is opaque to clients.
Cursor Stability¶
Offset-based cursors are best-effort and may produce duplicate or skipped results when underlying data changes between requests. For strict cursor stability, use backend-native cursors.
Complete Example¶
Schema:
type Organization implements Node {
id: ID!
name: String!
members(first: Int, after: String): MemberConnection!
}
type MemberConnection @connection {
edges: [MemberEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type MemberEdge @edge {
node: User!
cursor: String!
role: MemberRole!
joinedAt: DateTime!
}
enum MemberRole {
ADMIN
MEMBER
VIEWER
}
Resolver:
@Resolver
class OrganizationMembersResolver : OrganizationMembersFieldResolver() {
@Inject lateinit var memberService: MemberService
override suspend fun resolve(ctx: Context): MemberConnection {
val orgId = ctx.source.id
val (offset, limit) = ctx.arguments.toOffsetLimit()
val response = memberService.getMembers(
organizationId = orgId.internalID,
offset = offset,
limit = limit + 1
)
return MemberConnection.Builder(ctx)
.fromSlice(
items = response.members,
hasNextPage = response.members.size > limit
) { member ->
MemberEdge.Builder(ctx)
.node(ctx.nodeFor(member.userId))
.role(member.role)
.joinedAt(member.joinedAt)
.build()
}
.totalCount(response.totalCount)
.build()
}
}
Query:
query {
organization(id: "org123") {
name
members(first: 10) {
edges {
node {
id
name
}
role
joinedAt
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
}
Choosing an Approach¶
| Backend Support | Method | Notes |
|---|---|---|
| Native cursors | fromEdges() |
Pass through backend cursors directly |
| Offset/limit | fromSlice() |
Encodes offsets as cursors automatically |
| Full list | fromList() |
Handles slicing and cursor encoding |
Best Practices¶
- Fetch limit + 1 items to efficiently determine
hasNextPage - Include
totalCountwhen available for UI pagination controls - Set reasonable defaults for page size (typically 10-50 items)
- Keep cursors opaque—don't expose internal format to clients
- Use
fromSlice()for offset/limit backends to get automatic cursor handling