Skip to main content
Viaduct implements the Relay Connection specification for cursor-based pagination, providing a standardized approach to paginating large datasets.

Why Relay Connections?

Cursor-based

More stable than offset pagination when data changes between requests

Bidirectional

Supports both forward (first/after) and backward (last/before) traversal

Standardized

Well-understood pattern across the GraphQL ecosystem

Rich Metadata

Provides PageInfo for building pagination controls

Defining Connections

Schema Setup

Define connection and edge types with @connection and @edge directives:
schema/User.graphqls
type User implements Node {
  id: ID!
  name: String!
  
  # Paginated list of posts
  posts(first: Int, after: String): PostConnection! @resolver
  followers(first: Int, after: String): UserConnection! @resolver
}

type UserConnection @connection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int  # Optional
}

type UserEdge @edge {
  node: User!
  cursor: String!
  followedAt: DateTime  # Optional edge metadata
}
Connection requirements:
  • Name must end with Connection
  • Must have edges: [<EdgeType>!]! field
  • Must have pageInfo: PageInfo! field
  • Use @connection directive
Edge requirements:
  • Name must end with Edge
  • Must have node field (any type except list)
  • Must have cursor: String! field
  • Use @edge directive

Pagination Arguments

Add pagination arguments to fields returning connections:
extend type Query {
  # Forward pagination
  users(first: Int, after: String): UserConnection! @resolver
  
  # Backward pagination
  recentUsers(last: Int, before: String): UserConnection! @resolver
  
  # Bidirectional pagination
  allUsers(
    first: Int
    after: String
    last: Int
    before: String
  ): UserConnection! @resolver
}
first
Int
Number of items to fetch from the start (forward pagination)
after
String
Cursor to start after (exclusive) - fetch items after this cursor
last
Int
Number of items to fetch from the end (backward pagination)
before
String
Cursor to end before (exclusive) - fetch items before this cursor

PageInfo

PageInfo provides metadata about the current page:
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
Viaduct automatically creates the PageInfo type if it doesn’t exist in your schema. You can also define it explicitly, but it must conform exactly to the Relay specification.

Building Connections

Viaduct provides three builder methods for different pagination scenarios:

fromSlice() - Offset/Limit Backends

Best for backends using offset/limit pagination:
import com.example.myapp.resolverbases.QueryResolvers
import jakarta.inject.Inject
import viaduct.api.Resolver
import viaduct.api.grts.UserConnection

@Resolver
class UsersQueryResolver @Inject constructor(
    private val userService: UserService
) : QueryResolvers.Users() {
    override suspend fun resolve(ctx: Context): UserConnection {
        // Convert cursor arguments to offset/limit
        val (offset, limit) = ctx.arguments.toOffsetLimit()
        
        // Fetch limit + 1 to check if there are more results
        val users = userService.getUsers(
            offset = offset,
            limit = limit + 1
        )
        
        // Build connection from slice
        return UserConnection.Builder(ctx)
            .fromSlice(
                items = users,
                hasNextPage = users.size > limit
            ) { user ->
                // Map each item to a node reference
                ctx.nodeFor(user.id)
            }
            .build()
    }
}
Fetch limit + 1 to efficiently determine if there are more pages without a separate count query.

fromEdges() - Native Cursor Backends

Best for backends that natively support cursor-based pagination:
@Resolver
class UserFollowersResolver @Inject constructor(
    private val followerService: FollowerService
) : UserResolvers.Followers() {
    override suspend fun resolve(ctx: Context): UserConnection {
        val userId = ctx.objectValue.getId().internalID
        
        // Backend returns cursors directly
        val response = followerService.getFollowers(
            userId = userId,
            cursor = ctx.arguments.after,
            limit = ctx.arguments.first ?: 20
        )
        
        return UserConnection.Builder(ctx)
            .fromEdges(
                edges = response.followers.map { follower ->
                    UserEdge.Builder(ctx)
                        .node(ctx.nodeFor(follower.userId))
                        .cursor(follower.cursor)
                        .followedAt(follower.followedAt)
                        .build()
                },
                hasNextPage = response.hasMore,
                hasPreviousPage = response.hasPrevious
            )
            .build()
    }
}

fromList() - Full Dataset

Best when you have the complete dataset in memory:
@Resolver
class UserPostsResolver @Inject constructor(
    private val postService: PostService
) : UserResolvers.Posts() {
    override suspend fun resolve(ctx: Context): PostConnection {
        val userId = ctx.objectValue.getId().internalID
        
        // Fetch all posts for this user
        val posts = postService.getUserPosts(userId)
        
        // Viaduct handles slicing based on arguments
        return PostConnection.Builder(ctx)
            .fromList(posts) { post ->
                ctx.nodeFor(post.id)
            }
            .build()
    }
}
fromList() should only be used for small datasets that fit comfortably in memory.

Converting Arguments

Use toOffsetLimit() to convert cursor arguments to offset/limit:
val (offset, limit) = ctx.arguments.toOffsetLimit()

// Default limit is used if 'first' is not provided
val (offset, limit) = ctx.arguments.toOffsetLimit(defaultLimit = 20)
Valid argument combinations:
ArgumentsResult
NoneFirst page with default limit
first: 10First 10 items
first: 10, after: "cursor"10 items after cursor
after: "cursor" onlyDefault limit after cursor
last: 10, before: "cursor"Last 10 items before cursor

Backward Pagination with Total Count

When using last without before, you need the total count:
if (ctx.arguments.requiresTotalCountForOffsetLimit()) {
    val totalCount = userService.getUserCount()
    val (offset, limit) = ctx.arguments.toOffsetLimit(totalCount)
}

Adding Custom Fields

Edge Metadata

Add extra fields to edges:
type FollowerEdge @edge {
  node: User!
  cursor: String!
  
  # Custom edge fields
  followedAt: DateTime!
  notificationsEnabled: Boolean!
}
UserEdge.Builder(ctx)
    .node(ctx.nodeFor(followerId))
    .cursor(cursor)
    .followedAt(follower.createdAt)
    .notificationsEnabled(follower.notifications)
    .build()

Connection Metadata

Add extra fields to connections:
type PostConnection @connection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  
  # Custom connection fields
  totalCount: Int!
  publishedCount: Int!
}
PostConnection.Builder(ctx)
    .fromSlice(posts, hasNextPage = hasMore) { ctx.nodeFor(it.id) }
    .totalCount(response.total)
    .publishedCount(response.published)
    .build()

Filtering and Sorting

Combine pagination with filters:
enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

enum PostSortOrder {
  CREATED_AT_ASC
  CREATED_AT_DESC
  TITLE_ASC
}

extend type Query {
  posts(
    first: Int
    after: String
    status: PostStatus
    sortBy: PostSortOrder = CREATED_AT_DESC
  ): PostConnection! @resolver
}
@Resolver
class PostsQueryResolver @Inject constructor(
    private val postService: PostService
) : QueryResolvers.Posts() {
    override suspend fun resolve(ctx: Context): PostConnection {
        val (offset, limit) = ctx.arguments.toOffsetLimit()
        
        val posts = postService.getPosts(
            offset = offset,
            limit = limit + 1,
            status = ctx.arguments.status,
            sortOrder = ctx.arguments.sortBy
        )
        
        return PostConnection.Builder(ctx)
            .fromSlice(
                items = posts,
                hasNextPage = posts.size > limit
            ) { post ->
                ctx.nodeFor(post.id)
            }
            .build()
    }
}

Real-World Examples

@Resolver
class AllCharactersResolver @Inject constructor(
    private val characterRepository: CharacterRepository
) : QueryResolvers.AllCharacters() {
    override suspend fun resolve(ctx: Context): List<Character> {
        val limit = ctx.arguments.limit ?: 20
        val characters = characterRepository.findAll(limit)
        
        return characters.map { character ->
            ctx.nodeFor(character.id)
        }
    }
}

Querying Connections

Forward Pagination

query GetUsers {
  users(first: 10) {
    edges {
      node {
        id
        name
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Next Page

query GetNextPage {
  users(first: 10, after: "cursor-from-previous-query") {
    edges {
      node {
        id
        name
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Backward Pagination

query GetPreviousPage {
  users(last: 10, before: "cursor-from-current-page") {
    edges {
      node {
        id
        name
      }
      cursor
    }
    pageInfo {
      hasPreviousPage
      startCursor
    }
  }
}

Best Practices

1

Fetch limit + 1

Always fetch one more item than requested to efficiently check for more pages:
val items = service.fetch(offset, limit + 1)
val hasNextPage = items.size > limit
2

Set reasonable defaults

Provide default page sizes to prevent large queries:
val limit = ctx.arguments.first ?: 20
3

Include totalCount when available

Add totalCount to help clients build pagination UI:
.totalCount(response.total)
4

Use fromSlice for offset/limit

Let Viaduct handle cursor encoding:
.fromSlice(items, hasNextPage) { ctx.nodeFor(it.id) }
5

Keep cursors opaque

Don’t expose internal cursor format to clients. Viaduct handles encoding/decoding automatically.

Cursor Stability

Offset-based cursors may produce duplicate or skipped results when underlying data changes between requests. For strict cursor stability, use backend-native cursors with fromEdges().

Choosing an Approach

Backend SupportMethodWhen to Use
Native cursorsfromEdges()Backend provides stable cursors (e.g., DynamoDB, MongoDB)
Offset/limitfromSlice()SQL databases with OFFSET/LIMIT
Small datasetsfromList()Complete dataset fits in memory

Next Steps

Global IDs

Learn about type-safe global identifiers

Testing

Test your connection resolvers

Build docs developers (and LLMs) love