You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Relay

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:

  1. The data itself (through edges and nodes)
  2. Pagination metadata (through PageInfo)
  3. 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:

FieldTypePurpose
edges[Edge]Array of edge objects containing nodes and cursors
pageInfoPageInfo!Metadata about pagination state
totalCountIntOptional: 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:

FieldTypeMeaning
hasNextPageBoolean!Can you fetch more items forward?
hasPreviousPageBoolean!Can you fetch more items backward?
startCursorStringCursor of the first item in current page
endCursorStringCursor 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:

ArgumentTypePurpose
firstIntFetch first N items (forward pagination)
afterStringCursor to start after (forward pagination)
lastIntFetch last N items (backward pagination)
beforeStringCursor 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:

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." πŸš€