Skip to content
Back to skills

Graphql Pagination Patterns

ASecurity

> Implement cursor-based and offset pagination in GraphQL using the Relay connection specification

  • 20 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 11, 2026
developmenttypescriptnodeapidatabaseperformance

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 11, 2026

npx -y skills add Intense-Visions/harness-engineering --skill graphql-pagination-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Graphql Pagination Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Graphql Pagination Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/intense-visions-graphql-pagination-patterns/badge)](https://www.skillsdirectory.com/skills/intense-visions-graphql-pagination-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
# GraphQL Pagination Patterns

> Implement cursor-based and offset pagination in GraphQL using the Relay connection specification

## When to Use

- Returning lists of items that may grow unboundedly
- Building paginated feeds, search results, or admin tables
- Choosing between cursor-based and offset-based pagination
- Implementing infinite scroll or "load more" UI patterns
- Ensuring consistent pagination when items are added or removed

## Instructions

1. **Use the Relay connection spec for cursor-based pagination.** Even if you do not use Relay on the client, the `Connection/Edge/PageInfo` pattern is the industry standard for GraphQL pagination.

```graphql
type Query {
  users(first: Int, after: String, last: Int, before: String): UserConnection!
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type UserEdge {
  node: User!
  cursor: String!
}

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

2. **Implement cursor encoding with opaque strings.** Cursors should be opaque to clients — base64-encode the underlying value. Never expose raw database IDs or offsets as cursors.

```typescript
function encodeCursor(id: string): string {
  return Buffer.from(`cursor:${id}`).toString('base64');
}

function decodeCursor(cursor: string): string {
  const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
  return decoded.replace('cursor:', '');
}
```

3. **Build the resolver to handle `first/after` (forward) and `last/before` (backward) pagination.**

```typescript
const resolvers = {
  Query: {
    users: async (_parent, { first, after, last, before }, { db }) => {
      const limit = first ?? last ?? 20;
      const afterId = after ? decodeCursor(after) : null;
      const beforeId = before ? decodeCursor(before) : null;

      const users = await db.users.findPaginated({
        limit: limit + 1, // fetch one extra to determine hasNextPage
        afterId,
        beforeId,
        direction: last ? 'backward' : 'forward',
      });

      const hasMore = users.length > limit;
      const nodes = hasMore ? users.slice(0, limit) : users;

      if (last) nodes.reverse();

      return {
        edges: nodes.map((user) => ({
          node: user,
          cursor: encodeCursor(user.id),
        })),
        pageInfo: {
          hasNextPage: first ? hasMore : false,
          hasPreviousPage: last ? hasMore : false,
          startCursor: nodes[0] ? encodeCursor(nodes[0].id) : null,
          endCursor: nodes[nodes.length - 1] ? encodeCursor(nodes[nodes.length - 1].id) : null,
        },
      };
    },
  },
};
```

4. **Include `totalCount` when clients need it** (e.g., for "showing 1-20 of 342"). Be aware this requires a separate `COUNT(*)` query, which can be expensive on large tables.

5. **For simple use cases, offset pagination is acceptable.** Use it for admin dashboards, data tables, or any context where "jump to page N" is needed and data does not change frequently.

```graphql
type Query {
  users(offset: Int, limit: Int): UserList!
}

type UserList {
  items: [User!]!
  totalCount: Int!
  hasMore: Boolean!
}
```

6. **On the client, use `fetchMore` to load additional pages.**

```typescript
const { data, fetchMore } = useQuery(GET_USERS, { variables: { first: 20 } });

const loadMore = () => {
  fetchMore({
    variables: { after: data.users.pageInfo.endCursor },
    updateQuery: (prev, { fetchMoreResult }) => ({
      users: {
        ...fetchMoreResult.users,
        edges: [...prev.users.edges, ...fetchMoreResult.users.edges],
      },
    }),
  });
};
```

7. **Set sensible defaults and maximums for `first`/`limit`.** Default to 20, cap at 100. This prevents clients from requesting unbounded result sets.

```typescript
const limit = Math.min(first ?? 20, 100);
```

8. **Use `@connection` directive (Apollo Client) to give paginated fields a stable cache key** when the same field is queried with different pagination arguments.

## Details

**Cursor vs. offset trade-offs:**

- **Cursor-based:** Stable under concurrent inserts/deletes, efficient with indexed columns (e.g., `WHERE id > cursor`), no "page drift." Cannot jump to arbitrary pages.
- **Offset-based:** Simple to implement, supports "jump to page N." Degrades with large offsets (`OFFSET 10000` scans and discards rows), unstable when items are inserted/deleted between pages.

**Cursor implementation strategies:**

- **ID-based:** `WHERE id > :cursor ORDER BY id` — simple, efficient, works when ordering by primary key
- **Timestamp-based:** `WHERE created_at > :cursor ORDER BY created_at` — use a composite cursor (timestamp + id) for ties
- **Composite:** Encode multiple sort values into the cursor for multi-column sorting

**Performance considerations:**

- Fetch `limit + 1` to determine `hasNextPage` without a separate count query
- Use indexed columns for cursor comparison (`WHERE` clause must hit an index)
- Cache `totalCount` separately if it is expensive and does not need to be real-time
- For keyset pagination on composite sorts, build the `WHERE` clause dynamically

**Apollo Client cache integration:** Apollo's `offsetLimitPagination()` and `relayStylePagination()` type policies handle merging paginated results in the cache automatically.

## Source

https://relay.dev/graphql/connections.htm

## Process

1. Read the instructions and examples in this document.
2. Apply the patterns to your implementation, adapting to your specific context.
3. Verify your implementation against the details and edge cases listed above.

## Harness Integration

- **Type:** knowledge — this skill is a reference document, not a procedural workflow.
- **No tools or state** — consumed as context by other skills and agents.
- **related_skills:** graphql-schema-design, graphql-resolver-pattern, graphql-performance-patterns, api-pagination-cursor, api-pagination-offset, api-pagination-keyset

## Success Criteria

- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.

Files in this skill

  • SKILL.md6 KB
  • skill.yaml821 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…