Skip to content

GraphQL Pagination — Keyset, Offset, and Cursor-Based Pagination Explained

DodaTech Updated 2026-06-28 4 min read

In this tutorial, you will learn about Graphql Pagination. We cover key concepts, practical examples, and best practices to help you master this topic.

Pagination in GraphQL lets clients request slices of a list without fetching the entire dataset, reducing response size and improving performance through limit, offset, cursor, and connection patterns.

What You'll Learn

  • Offset-based pagination with limit/skip
  • Cursor-based pagination with after/before
  • Relay connection specification
  • Handling total counts and page info
  • Performance considerations for large datasets

Why It Matters

Without pagination, a single query could return thousands of records, overwhelming the server and the client. GraphQL's flexible pagination lets clients request exactly what they need. DodaTech's Durga Antivirus Pro uses cursor-based pagination to load threat logs across 10 million+ devices, ensuring the dashboard loads in under 2 seconds.

Real-World Use

A threat intelligence dashboard shows 50 recent alerts with a "Load More" button. Each page returns the next 50 alerts using cursor pagination. When the user searches by date range, the server applies filters before paginating. The same API serves the mobile app with 10-item pages and the web dashboard with 50-item pages.

flowchart LR
    A["Client Request\n{first: 10, after: 'cursor'}"] --> B["GraphQL Server"]
    B --> C["DB Query\nWHERE id > cursor\nLIMIT 11"]
    C --> D["Response\n{edges: [...],\npageInfo: {hasNextPage}}"]
    D --> A
    style A fill:#dbeafe,stroke:#2563eb
    style C fill:#fef3c7,stroke:#d97706

Code Examples

Example 1: Offset-Based Pagination

type Query {
  devices(limit: Int, offset: Int): [Device!]!
}
const resolvers = {
  Query: {
    devices: async (_, { limit = 20, offset = 0 }, context) => {
      const items = await context.db.devices
        .find()
        .sort({ createdAt: -1 })
        .skip(offset)
        .limit(limit)
        .toArray();
      return items;
    },
  },
};

Example 2: Cursor-Based Pagination

type Query {
  devices(first: Int, after: String): DeviceConnection!
}

type DeviceConnection {
  edges: [DeviceEdge!]!
  pageInfo: PageInfo!
}

type DeviceEdge {
  node: Device!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
const resolvers = {
  Query: {
    devices: async (_, { first = 20, after }, context) => {
      const query = {};
      if (after) {
        const decoded = Buffer.from(after, 'base64').toString();
        query._id = { $gt: decoded };
      }
      const items = await context.db.devices
        .find(query)
        .sort({ _id: 1 })
        .limit(first + 1)
        .toArray();
      const hasNextPage = items.length > first;
      if (hasNextPage) items.pop();
      const edges = items.map(item => ({
        node: item,
        cursor: Buffer.from(item._id.toString()).toString('base64'),
      }));
      return {
        edges,
        pageInfo: {
          hasNextPage,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        },
      };
    },
  },
};

Example 3: Sorting and Filtering with Pagination

type Query {
  threats(
    first: Int
    after: String
    severity: Severity
    sortBy: ThreatSortField
  ): ThreatConnection!
}

enum ThreatSortField {
  SEVERITY
  DETECTED_AT
  DEVICE_NAME
}
async function paginatedQuery(params) {
  const { first, after, severity, sortBy } = params;
  const query = {};
  if (severity) query.severity = severity;
  const sortField = sortBy === 'SEVERITY' ? 'severity' : 'detectedAt';
  const cursor = after
    ? Buffer.from(after, 'base64').toString()
    : null;
  if (cursor) {
    query._id = { $gt: cursor };
  }
  const items = await db.threats
    .find(query)
    .sort({ [sortField]: -1, _id: 1 })
    .limit(first + 1)
    .toArray();
  const hasNext = items.length > first;
  if (hasNext) items.pop();
  const edges = items.map(t => ({
    node: t,
    cursor: Buffer.from(t._id.toString()).toString('base64'),
  }));
  return { edges, pageInfo: { hasNextPage: hasNext, endCursor: edges.at(-1)?.cursor } };
}

Common Mistakes

  1. Using offset pagination on large datasets — skip+limit becomes slow beyond 10K rows because the database must scan all skipped records.
  2. Not sorting by a unique field — if sorting by name and two devices share a name, cursor pagination may skip or duplicate results.
  3. Exposing raw database IDs as cursors — encode cursors (e.g., base64) to prevent clients from guessing record order or count.
  4. Forgetting to request one extra record — the standard pattern requests first + 1 records and uses the extra to determine hasNextPage.
  5. Mixing offset and cursor pagination in the same resolver — pick one approach and apply it consistently across all list fields.

Practice Questions

  1. Why is cursor-based pagination preferred over offset-based for large datasets?
  2. How does the Relay connection specification structure paginated responses?
  3. What is the purpose of encoding cursors in base64?
  4. How does hasNextPage work without knowing the total count?
  5. When would you still choose offset-based pagination over cursor-based?

Challenge: Write a pagination resolver for threatLogs(first: Int, after: String, severity: Severity): ThreatLogConnection! that supports filtering by severity, sorting by timestamp descending, and returns proper page info with total count included as a non-standard field.

Mini Project

Build a GraphQL API for a logging dashboard with cursor-based pagination on all list endpoints. Include filters for date range, severity, and device ID. Implement both forward (first/after) and backward (last/before) pagination. Add a total count field for UI progress bars.

FAQ

What is the difference between offset and cursor pagination?

Offset pagination uses skip/limit and lets clients jump to any page number. Cursor pagination uses opaque cursors and is more stable — inserting or deleting records won't shift results.

Should I always use Relay-style pagination?

Relay-style pagination is best for public APIs and when clients need to paginate forward and backward. For simple internal APIs, offset pagination may suffice.

How do I implement search with pagination?

Apply the search filter before paginating: query the database with the search condition, sort results, then apply limit/cursor logic. The search index (e.g., Elasticsearch) handles the filtering.

Can I return totalCount with cursor pagination?

Yes, but it requires a separate COUNT query. Many APIs include totalCount as an optional field that clients can request when needed.

How does pagination work with DataLoader?

DataLoader batches individual record loads. For paginated queries, you typically bypass DataLoader and query the database directly with pagination logic.

What's Next

Learn about rate limiting for GraphQL APIs

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro