Skip to content

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 edges field with type [<EdgeType>!]! where the edge type has the @edge directive
  • Have a pageInfo: PageInfo! field

Edge Type

An Edge wraps a node and provides its cursor for pagination. Use the @edge directive:

type UserEdge @edge {
  node: User!
  cursor: String!
  role: String  # Optional additional fields
}

A type with @edge must have:

  • A node field (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

interface Connection<E : Edge<N>, N> : Object

interface Edge<N> : Object

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:

  1. Converts pagination arguments to offset/limit via toOffsetLimit()
  2. Builds edges with automatically encoded offset cursors
  3. Sets pageInfo with correct hasNextPage and hasPreviousPage values

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:

val (offset, limit) = ctx.arguments.toOffsetLimit()

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:

  • first and last must be > 0 if specified
  • after and before must 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 totalCount when 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