iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Pagination / Cursors

Pagination is GraphQL’s answer to “don’t return all 10 million records”. The two standard patterns are offset-based (simple, but breaks on inserts) and cursor-based (Relay-style connections, robust at scale). Pick cursors for feeds; offset for static reports.

Offset, cursor, connections, Relay

EXAMPLE
# 1) Offset-based — simple but fragile
type Query {
    posts(limit: Int = 20, offset: Int = 0): [Post!]!
    postsCount: Int!
}

# Client:
# query { posts(limit: 20, offset: 40) { id title } postsCount }

# Problems:
# • If new posts insert at the top, you SEE DUPLICATES or SKIP
# • Performance: OFFSET 1000000 scans 1M rows
# • Limited expressiveness; clients can't request 'after this id'

# 2) Cursor-based — the Relay Connection spec
type Query {
    posts(first: Int, after: String, last: Int, before: String, status: PostStatus): PostConnection!
}

type PostConnection {
    edges:    [PostEdge!]!
    pageInfo: PageInfo!
    totalCount: Int                 # optional; expensive at scale
}

type PostEdge {
    cursor: String!
    node:   Post!
}

type PageInfo {
    startCursor:     String
    endCursor:       String
    hasNextPage:     Boolean!
    hasPreviousPage: Boolean!
}

# 3) Cursor encoding
# Cursors are OPAQUE strings — base64-encoded composite of stable sort keys.
#
# For 'sort by createdAt DESC, then id DESC':
#   cursor = base64('2024-01-15T03:21:00Z|abc1234')
#
# Server decodes + uses for WHERE clause:
#   WHERE (createdAt, id) < (decoded_at, decoded_id)

# 4) Resolver sketch
const resolvers = {
    Query: {
        posts: async (_p, { first = 20, after, status }, ctx) => {
            const decoded = after ? decodeCursor(after) : null;
            const where = {
                status: status ?? undefined,
                ...(decoded && {
                    OR: [
                        { createdAt: { lt: decoded.createdAt } },
                        { createdAt: decoded.createdAt, id: { lt: decoded.id } },
                    ],
                }),
            };
            const rows = await ctx.db.post.findMany({
                where,
                orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
                take: first + 1,                         // peek for hasNextPage
            });
            const hasNextPage = rows.length > first;
            const items = hasNextPage ? rows.slice(0, -1) : rows;
            const edges = items.map((node) => ({
                cursor: encodeCursor({ createdAt: node.createdAt, id: node.id }),
                node,
            }));
            return {
                edges,
                pageInfo: {
                    startCursor: edges[0]?.cursor ?? null,
                    endCursor:   edges[edges.length - 1]?.cursor ?? null,
                    hasNextPage,
                    hasPreviousPage: !!after,
                },
            };
        },
    },
};

# 5) Client query
# query Feed($cursor: String) {
#     posts(first: 20, after: $cursor) {
#         edges { cursor node { id title createdAt } }
#         pageInfo { hasNextPage endCursor }
#     }
# }
#
# Next page: { cursor: result.posts.pageInfo.endCursor }
# Stop when hasNextPage is false.

# 6) Why cursor over offset
# • Stable under inserts/deletes — cursors point at the actual row, not a row count
# • Fast: WHERE on indexed sort key uses btree skip
# • Pageable from any point — bookmark a cursor, return tomorrow
# • Composes with filters — cursor + status + tag all in one query

# 7) totalCount — be careful
# COUNT(*) on large tables is SLOW.
# • Use approximate counts (Postgres: relpages * average_rows_per_page)
# • Or drop totalCount and use 'infinite scroll' UI
# • Or pre-compute via materialised view

# 8) Bidirectional pagination — last + before
# Same idea but in reverse. Less common; many apps support next only.

# 9) Apollo Client auto-pagination
# fetchMore({ variables: { cursor } }) and merge in cache.
# Use field policies for connection-shaped fields:
#   posts: {
//      keyArgs: ['status'],
//      merge(existing, incoming) {
//          return {
//              ...incoming,
//              edges: [...(existing?.edges ?? []), ...incoming.edges],
//          };
//      },
//   }

# 10) Server-side variants
# • Keyset pagination — what we showed; column-based
# • Seek pagination — alias for keyset
# • Page-based with stable sort + tiebreaker — simpler client; slightly less robust
# • Cursor over multiple sort keys — composite cursor (createdAt, id)

# 11) Pagination + filters
# When the filter changes, RESET the cursor. Cursor encodes WHERE, but only for the active query.
# UI: clear scroll position + cursor when status / search changes.

# 12) Pagination + sort order
# Cursor must encode the SORT KEY plus tiebreaker.
# Changing sort = new cursor scheme; reset pagination.

# 13) Infinite scroll vs paged UI
# • Infinite scroll → cursor-based + IntersectionObserver
# • Pages 1, 2, 3, … → offset-based works if data is static
# • 'Load more' button → either pattern; cursor preferred

# 14) Common bugs
# • Using offset without ORDER BY → non-deterministic; ALWAYS sort
# • Offset duplicates / skips when data churns → switch to cursor
# • Cursor includes only one column (createdAt) and ties exist → records skipped; include id as tiebreaker
# • Encoding cursor with PII → leaks info; encrypt or use opaque ids
# • Cursor signed but not verified → tampering allows reading other slices
# • totalCount on huge tables — SLOW; remove or approximate
# • Returning empty edges + hasNextPage: true — clients loop forever
# • Cursor schema changes deploy mid-flight — old cursors become invalid; version cursors

Why it matters

Cursor-based pagination via Relay-style Connections is the production-grade pattern: stable under inserts, fast on indexes, composable with filters. Encode a composite cursor (sort key + id tiebreaker), peek at first + 1 rows to decide hasNextPage, and drop totalCount from large datasets or compute it approximately.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
# Relay cursor pagination
query { users(first: 20, after: "cursor") { edges { node { id } } pageInfo { endCursor hasNextPage } } }
Try it Yourself »

Discussion

Loading…