Connection Specification
Understanding edges, nodes, pageInfo, and cursor-based pagination
Connection Specification in Relay
Master GraphQL pagination with Relay's connection specification using free flashcards and spaced repetition practice. This lesson covers connection structure, cursor-based pagination, and edge-node patternsβessential concepts for building scalable GraphQL APIs that handle large datasets efficiently.
Welcome to Connection Specification π
When building modern web applications, you rarely want to load thousands of records at once. Imagine fetching all products from an e-commerce site with 50,000 itemsβyour app would freeze, waste bandwidth, and frustrate users. This is where Relay's Connection Specification shines. π
The connection spec is a standardized way to paginate data in GraphQL. Rather than inventing your own pagination approach (and dealing with inconsistencies across your API), Relay provides a battle-tested pattern used by Facebook, GitHub, Shopify, and countless other companies. It uses cursor-based pagination instead of offset-based approaches, making it more reliable for real-time data that changes frequently.
In this lesson, you'll learn:
- π What connections, edges, and nodes are
- π How cursor-based pagination works
- βοΈ The structure of PageInfo
- π οΈ How to implement connections in your schema
- π― Best practices for cursor design
By the end, you'll understand why this pattern has become the gold standard for GraphQL pagination and how to implement it in your own APIs.
Core Concepts π§
What is a Connection?
A connection is a specialized GraphQL type that represents a paginated list of items. Instead of returning a simple array like [item1, item2, item3], a connection wraps your data in a structure that provides:
- The data itself (through edges and nodes)
- Pagination metadata (through PageInfo)
- Cursors for fetching previous/next pages
Think of a connection as a "window" into a larger dataset. You're not seeing everythingβjust what fits in your current view, with the ability to move that window forward or backward.
βββββββββββββββββββββββββββββββββββββββββββββββββββ β FULL DATASET (10,000 items) β β βββββ¬ββββ¬ββββ¬ββββ¬ββββ¬ββββ¬ββββ¬ββββ¬ββββ¬ββββ β β β 1 β 2 β 3 β 4 β 5 β 6 β 7 β 8 β 9 β...β β β βββββ΄ββββ΄ββββ΄ββββ΄ββββ΄ββββ΄ββββ΄ββββ΄ββββ΄ββββ β β β β π Current Connection (viewing items 3-5): β β βββββββββββββββββββββββββββββββββββββ β β β βββββ¬ββββ¬ββββ β β β β β 3 β 4 β 5 β β Your "window" β β β β βββββ΄ββββ΄ββββ β β β βββββββββββββββββββββββββββββββββββββ β β β Cursor β Cursor β β β (previous) | (next) β β hasNextPage β βββββββββββββββββββββββββββββββββββββββββββββββββββ
The Connection Type Structure
Every connection type follows this pattern:
| Field | Type | Purpose |
|---|---|---|
| edges | [Edge] | Array of edge objects containing nodes and cursors |
| pageInfo | PageInfo! | Metadata about pagination state |
| totalCount | Int | Optional: Total number of items (can be expensive) |
Here's what a connection type looks like in GraphQL schema language:
type ProductConnection {
edges: [ProductEdge]
pageInfo: PageInfo!
totalCount: Int
}
type ProductEdge {
node: Product
cursor: String!
}
type Product {
id: ID!
name: String!
price: Float!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
π‘ Why the edge wrapper? You might wonder why we don't just return nodes directly. Edges allow us to attach metadata to each itemβmost importantly, the cursor. They also provide a place for future extensions like analytics data per item.
Understanding Cursors π―
A cursor is an opaque string that marks a specific position in your dataset. Unlike offset-based pagination (LIMIT 10 OFFSET 20), cursors remain stable even when data changes.
Offset-based problems:
// Initially fetch items 11-20
Query: LIMIT 10 OFFSET 10
Results: [Item11, Item12, ..., Item20]
// β οΈ Someone deletes Item5
// You click "Next Page" expecting items 21-30
Query: LIMIT 10 OFFSET 20
Results: [Item21, Item22, ..., Item30]
// β You MISSED Item20! It shifted into the gap.
Cursor-based solution:
// Fetch first 10 items
Query: first: 10
Returns: Items with cursors ["cur_1", "cur_2", ..., "cur_10"]
// β οΈ Someone deletes Item5
// Fetch next 10 after cursor "cur_10"
Query: first: 10, after: "cur_10"
Returns: Items after position "cur_10" (stable reference)
// β
You get the correct next items!
Cursors are typically base64-encoded strings that contain:
- The item's ID or unique identifier
- Optionally: sort field values (for complex sorting)
- Optionally: a timestamp or version
Example cursor (decoded): arrayconnection:0 or product:12345:2024-01-15
PageInfo: Your Navigation Dashboard π§
The PageInfo type tells you where you are and where you can go:
| Field | Type | Meaning |
|---|---|---|
| hasNextPage | Boolean! | Can you fetch more items forward? |
| hasPreviousPage | Boolean! | Can you fetch more items backward? |
| startCursor | String | Cursor of the first item in current page |
| endCursor | String | Cursor of the last item in current page |
These fields let you build pagination UI:
if (pageInfo.hasNextPage) {
// Show "Next" button
// On click: query with after: pageInfo.endCursor
}
if (pageInfo.hasPreviousPage) {
// Show "Previous" button
// On click: query with before: pageInfo.startCursor
}
Pagination Arguments π
Connections accept standardized arguments:
| Argument | Type | Purpose |
|---|---|---|
| first | Int | Fetch first N items (forward pagination) |
| after | String | Cursor to start after (forward pagination) |
| last | Int | Fetch last N items (backward pagination) |
| before | String | Cursor to end before (backward pagination) |
Forward pagination (most common):
query {
products(first: 10, after: "cursor_123") {
edges {
node { name }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
Backward pagination (for infinite scroll in reverse):
query {
products(last: 10, before: "cursor_123") {
edges {
node { name }
cursor
}
pageInfo {
hasPreviousPage
startCursor
}
}
}
β οΈ Don't mix directions! Avoid using first with before, or last with after. Stick to:
- Forward:
first+after - Backward:
last+before
Examples πΌ
Example 1: Basic Product Connection
Let's build a complete product connection from scratch.
Schema definition:
type Query {
products(first: Int, after: String): ProductConnection
}
type ProductConnection {
edges: [ProductEdge]
pageInfo: PageInfo!
totalCount: Int
}
type ProductEdge {
node: Product
cursor: String!
}
type Product {
id: ID!
name: String!
price: Float!
stock: Int!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Client query (page 1):
query {
products(first: 3) {
edges {
node {
id
name
price
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
Response:
{
"data": {
"products": {
"edges": [
{
"node": {
"id": "1",
"name": "Laptop",
"price": 999.99
},
"cursor": "YXJyYXljb25uZWN0aW9uOjA="
},
{
"node": {
"id": "2",
"name": "Mouse",
"price": 29.99
},
"cursor": "YXJyYXljb25uZWN0aW9uOjE="
},
{
"node": {
"id": "3",
"name": "Keyboard",
"price": 79.99
},
"cursor": "YXJyYXljb25uZWN0aW9uOjI="
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "YXJyYXljb25uZWN0aW9uOjI="
}
}
}
}
Client query (page 2):
query {
products(first: 3, after: "YXJyYXljb25uZWN0aW9uOjI=") {
edges {
node {
id
name
price
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
endCursor
}
}
}
π‘ Key insight: The client stores endCursor from the previous response and uses it as the after argument for the next page.
Example 2: Nested Connections (Users with Posts)
Connections can nest! A user might have a connection of posts:
Schema:
type Query {
users(first: Int, after: String): UserConnection
}
type UserConnection {
edges: [UserEdge]
pageInfo: PageInfo!
}
type UserEdge {
node: User
cursor: String!
}
type User {
id: ID!
name: String!
posts(first: Int, after: String): PostConnection
}
type PostConnection {
edges: [PostEdge]
pageInfo: PageInfo!
}
type PostEdge {
node: Post
cursor: String!
}
type Post {
id: ID!
title: String!
createdAt: String!
}
Query:
query {
users(first: 2) {
edges {
node {
name
posts(first: 2) {
edges {
node {
title
}
cursor
}
pageInfo {
hasNextPage
}
}
}
cursor
}
pageInfo {
hasNextPage
}
}
}
This fetches 2 users, and for each user, fetches their first 2 posts. Each connection maintains its own independent pagination state.
ββββββββββββββββββββββββββββββββββββββββ
β User Connection (first: 2) β
β ββββββββββββββ ββββββββββββββ β
β β User 1 β β User 2 β β
β β Posts β β β Posts β β β
β β βββββββ β β βββββββ β β
β β βPost1β β β βPost1β β β
β β βPost2β β β βPost2β β β
β β βββββββ β β βββββββ β β
β ββββββββββββββ ββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββ
β β
Cursor 1 Cursor 2
Example 3: Bidirectional Pagination (Chat History)
Imagine a chat app where users can scroll up (older messages) or down (newer messages):
Initial load (most recent 10 messages):
query {
chatMessages(last: 10) {
edges {
node {
text
timestamp
}
cursor
}
pageInfo {
hasPreviousPage
startCursor
}
}
}
Scroll up for older messages:
query {
chatMessages(last: 10, before: "cursor_of_oldest_visible") {
edges {
node {
text
timestamp
}
cursor
}
pageInfo {
hasPreviousPage
startCursor
}
}
}
π‘ Why last instead of first? Using last with before gives you the most recent N items before a cursorβperfect for loading older chat messages in reverse chronological order.
Example 4: Implementing Cursor Encoding
Cursors should be opaque (clients shouldn't parse them), but here's how they're typically implemented:
Simple ID-based cursor:
// Encoding
function encodeCursor(id) {
return Buffer.from(`arrayconnection:${id}`).toString('base64');
}
// Decoding
function decodeCursor(cursor) {
const decoded = Buffer.from(cursor, 'base64').toString('utf8');
const [, id] = decoded.split(':');
return id;
}
// Usage
const cursor = encodeCursor('42');
// Result: "YXJyYXljb25uZWN0aW9uOjQy"
const id = decodeCursor('YXJyYXljb25uZWN0aW9uOjQy');
// Result: "42"
Complex cursor with sorting:
function encodeCursor(item) {
const payload = {
id: item.id,
createdAt: item.createdAt,
score: item.score
};
return Buffer.from(JSON.stringify(payload)).toString('base64');
}
function decodeCursor(cursor) {
const decoded = Buffer.from(cursor, 'base64').toString('utf8');
return JSON.parse(decoded);
}
This allows you to implement "keyset pagination" for complex sorting (e.g., sort by score, then by date).
Resolver implementation (Node.js example):
const resolvers = {
Query: {
products: async (parent, { first = 10, after }) => {
let startIndex = 0;
if (after) {
const cursorId = decodeCursor(after);
startIndex = parseInt(cursorId) + 1;
}
const allProducts = await db.products.findAll();
const paginatedProducts = allProducts.slice(
startIndex,
startIndex + first
);
const edges = paginatedProducts.map((product, index) => ({
node: product,
cursor: encodeCursor(startIndex + index)
}));
const hasNextPage = startIndex + first < allProducts.length;
const hasPreviousPage = startIndex > 0;
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor
},
totalCount: allProducts.length
};
}
}
};
β οΈ Production note: This example uses in-memory slicing for simplicity. In production, push pagination logic to your database using LIMIT and WHERE id > ? clauses for performance.
Common Mistakes β οΈ
1. Returning Arrays Instead of Connections
β Wrong:
type Query {
products: [Product] # No pagination!
}
β Right:
type Query {
products(first: Int, after: String): ProductConnection
}
Why it matters: Without connections, clients have no way to paginate. They'll either get all items (slow) or need custom pagination arguments (inconsistent).
2. Not Making PageInfo Non-Nullable
β Wrong:
type ProductConnection {
edges: [ProductEdge]
pageInfo: PageInfo # Should be PageInfo!
}
β Right:
type ProductConnection {
edges: [ProductEdge]
pageInfo: PageInfo! # Always required
}
Why it matters: Clients depend on PageInfo to build navigation. It should never be null.
3. Using Sequential IDs as Cursors Without Encoding
β Wrong:
{
"cursor": "42" # Clients might assume this is just an ID
}
β Right:
{
"cursor": "YXJyYXljb25uZWN0aW9uOjQy" # Opaque base64
}
Why it matters: Opaque cursors prevent clients from making assumptions about cursor structure. If you later need to change cursor format (e.g., add sort fields), clients won't break.
4. Forgetting Edge Cases in hasNextPage Logic
β Wrong:
hasNextPage: edges.length === first
This fails when you request 10 items but only 8 remainβit incorrectly returns hasNextPage: false.
β Right:
hasNextPage: (startIndex + first) < totalCount
Always check against the total dataset, not just the returned page size.
5. Mixing Pagination Directions
β Wrong:
query {
products(first: 10, before: "cursor") { # Contradictory!
edges { node { name } }
}
}
β Right:
query {
products(first: 10, after: "cursor") { # Forward pagination
edges { node { name } }
}
}
or
query {
products(last: 10, before: "cursor") { # Backward pagination
edges { node { name } }
}
}
6. Not Validating Pagination Arguments
β Wrong: Allowing first: 10000 (performance nightmare)
β Right:
if (first > 100) {
throw new Error('Cannot request more than 100 items at once');
}
Always set reasonable limits (typically 25-100 items per page).
7. Including totalCount in Every Query
β οΈ Performance trap:
query {
products(first: 10) {
edges { node { name } }
totalCount # Requires COUNT(*) queryβexpensive!
}
}
π‘ Better: Only fetch totalCount when needed (e.g., for displaying "Page 1 of 50"). For infinite scroll, you don't need it at all.
Key Takeaways π―
π Quick Reference: Connection Specification
| Component | Purpose | Key Points |
|---|---|---|
| Connection | Container for paginated data | Contains edges and pageInfo |
| Edge | Wrapper for each item | Contains node and cursor |
| Node | The actual data item | Your business object (Product, User, etc.) |
| Cursor | Position marker | Opaque, base64-encoded, stable |
| PageInfo | Navigation metadata | Has next/previous, start/end cursors |
| first/after | Forward pagination | "Give me 10 items after this cursor" |
| last/before | Backward pagination | "Give me 10 items before this cursor" |
π Golden Rules
- β Always use connections for lists (not bare arrays)
- β Make PageInfo non-nullable
- β Encode cursors (make them opaque)
- β Set reasonable page size limits
- β Use database-level pagination for performance
- β Don't mix pagination directions (first+before, last+after)
- β Don't expose cursor internals to clients
- β Don't calculate totalCount unless needed
π Common Use Cases
| Social feeds | Use forward pagination (first/after) for infinite scroll |
| Chat history | Use backward pagination (last/before) to load older messages |
| Search results | Use forward pagination with filters |
| Admin tables | Forward pagination with totalCount for page numbers |
π§ Memory Device: Think of "ECNP" to remember the structure:
- Edges (contain nodes and cursors)
- Cursor (marks position)
- Node (your actual data)
- PageInfo (navigation metadata)
π€ Did you know? GitHub's GraphQL API uses connections extensively. When you browse repositories, stars, or followers, you're using Relay-style connections behind the scenes. The pattern handles millions of records efficiently.
π Real-world analogy: A connection is like reading a book:
- Nodes are the pages with content
- Cursors are bookmarks marking your position
- PageInfo tells you if there are more chapters ahead or behind
- Edges are the page tabs that help you find specific pages
You don't load the entire book into memoryβjust the pages in your current "window" of reading.
π Further Study
Official Resources:
- Relay Cursor Connections Specification - The official spec document
- GraphQL Pagination Best Practices - GraphQL Foundation's guide
- Apollo Server Pagination Guide - Implementation examples for Apollo Server
You now have a solid foundation in Relay's connection specification! Practice implementing connections in your own schemas, and remember: when dealing with lists, always think "connection first." π