Skip to content
Relay-Style Connections and Pagination in GraphQL 3 min

Relay-Style Connections and Pagination in GraphQL

SD
ScaleDojo
May 11, 2026
3 min read695 words
Relay-Style Connections and Pagination in GraphQL

Why Slack Abandoned Offset Pagination

Slack's message API originally used offset/limit pagination: GET /messages?offset=1000&limit=50. It worked fine until channels had 100K+ messages. At offset=50000, the database had to scan and skip 50,000 rows before returning 50. Query time degraded from 5ms to 2 seconds. Worse, if someone posted a new message while a user was paginating, the offset shifted and they either saw duplicate messages or missed one entirely. Slack switched to cursor-based pagination, and the Relay connection spec became the standard pattern for paginated GraphQL APIs. Facebook, GitHub, Shopify, and Stripe all adopted it because it solves both performance and consistency problems.

Offset vs Cursor Pagination

OFFSET PAGINATION (fragile):

  Page 1: SELECT * FROM posts ORDER BY id LIMIT 10 OFFSET 0
  Page 2: SELECT * FROM posts ORDER BY id LIMIT 10 OFFSET 10
  Page 50: SELECT * FROM posts ORDER BY id LIMIT 10 OFFSET 490
           ^ DB scans 500 rows, discards 490!

  Problem 1 - Performance:
  Offset 0:      5ms   (scan 10 rows)
  Offset 10000:  200ms (scan 10010 rows)
  Offset 100000: 2s    (scan 100010 rows)

  Problem 2 - Shifting window:
  User loads page 1 (posts 1-10)
  New post inserted! (all offsets shift +1)
  User loads page 2 (offset=10) -> sees post 10 AGAIN (duplicate!)

CURSOR PAGINATION (stable):

  Page 1: SELECT * FROM posts
          ORDER BY id LIMIT 10
          -- returns posts 1-10, cursor = post_10_id

  Page 2: SELECT * FROM posts
          WHERE id > 'post_10_id'  -- cursor
          ORDER BY id LIMIT 10
          -- Uses index! O(1) seek, not O(N) scan

  New post inserted? Doesn't matter.
  Cursor points to a fixed row, not a position.
  No duplicates, no skips.

The Relay Connection Structure

GraphQL Query Using Relay Connections:

  query {
    user(id: 42) {
      posts(first: 10, after: "Y3Vyc29yXzEw") {
        edges {
          node {
            id
            title
            createdAt
          }
          cursor    # opaque cursor for this specific edge
        }
        pageInfo {
          hasNextPage    # are there more items after this page?
          hasPreviousPage
          startCursor    # cursor of first item on this page
          endCursor      # cursor of last item (use as 'after')
        }
        totalCount       # optional: total items (can be expensive)
      }
    }
  }

Anatomy of the Response:

  {
    "posts": {
      "edges": [
        {
          "node": { "id": "11", "title": "GraphQL Tips" },
          "cursor": "Y3Vyc29yXzEx"    # base64(cursor_11)
        },
        {
          "node": { "id": "12", "title": "Scaling Redis" },
          "cursor": "Y3Vyc29yXzEy"    # base64(cursor_12)
        }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "Y3Vyc29yXzIw"   # pass this as 'after'
      },
      "totalCount": 156
    }
  }

Why This Structure Matters

Why Edges (not just nodes)?

  Edges can carry relationship metadata:

  friendships(first: 10) {
    edges {
      node { id, name, avatar }  # the friend
      cursor
      friendsSince               # metadata about the RELATIONSHIP
      mutualFriends              # not on User, on the edge
    }
  }

  Without edges, you'd have to put 'friendsSince' on the User
  type, which makes no sense (it's per-relationship, not per-user).

Why Opaque Cursors?

  Cursor = base64("created_at:2024-01-15T10:00:00Z,id:42")
  Client sees: "Y3JlYXRlZF9hdDoyMDI0LTAxL..."

  Why opaque?
  - Backend can change cursor format without breaking clients
  - Prevents clients from constructing/modifying cursors
  - Can encode composite keys (timestamp + id for stable ordering)

Navigation Directions:

  Forward:   first: 10, after: "cursorX"   (next 10 after X)
  Backward:  last: 10, before: "cursorY"   (prev 10 before Y)
  First page: first: 10                    (no cursor needed)

Interview Tip

When discussing pagination in any API design, explain why cursor-based beats offset: 'Offset pagination degrades linearly - the database must scan and skip N rows for offset N, making page 1000 much slower than page 1. With cursor-based pagination, the database seeks directly to the cursor position using an index, giving O(1) performance at any depth. Cursors are also stable - new inserts do not cause duplicate or missing items across pages. The Relay connection spec standardizes this with edges (for relationship metadata), nodes (the data), and pageInfo (for navigation). I make cursors opaque (base64-encoded) so the backend can change the cursor format without breaking clients.'

<
Relay-Style Connections and Pagination in GraphQL - Architecture Diagram
Architecture overview
/blockquote>

Key Takeaway

Relay-style connections standardize cursor-based pagination in GraphQL with edges (relationship + cursor), nodes (the data), and pageInfo (hasNextPage, endCursor). Cursor-based pagination gives O(1) performance at any depth and stable results even during concurrent writes. Use opaque cursors, support bidirectional navigation, and make totalCount optional since it can be expensive on large datasets.

Enjoyed this article?

Share it with your network to help others level up their system design skills.

Discussion0

Join the Discussion

Sign in to leave comments, reply to others, or like insights.

Sign In to ScaleDojo

No comments yet. Be the first to start the thread!

Related Articles

Enjoyed this content?

Your support keeps us creating free resources

We put a lot of hours into researching and writing these guides. If it helped you, consider buying us a coffee. Every bit goes toward keeping ScaleDojo's content free and growing.

$

One-time payment via Stripe. ScaleDojo account required.

ScaleDojo Logo
Initializing ScaleDojo