Skip to content
Codeloom

Courses / GraphQL Complete Guide

Lesson 7 of 18

GraphQL Pagination Patterns

Master GraphQL pagination with cursor-based and offset-based approaches, Relay-style connections, and infinite scroll implementation.

Intermediate 12 min read

What you'll learn

  • How offset-based pagination works and where it breaks down
  • How cursor-based pagination provides stable results
  • How to implement Relay-style connections with edges and pageInfo
  • How to build infinite scroll with Apollo Client
  • How to choose the right pagination pattern for your use case

Prerequisites

  • Basic GraphQL queries
  • Familiarity with database queries

Why Pagination Matters

Returning every record in a single response is not viable. A query for “all users” might return millions of rows, overwhelming both the server and the client. Pagination breaks large datasets into manageable chunks. GraphQL offers two fundamental approaches: offset-based and cursor-based. Each has tradeoffs that matter for your specific use case.

Offset-Based Pagination

Offset pagination uses limit and offset arguments, mirroring SQL’s LIMIT and OFFSET:

type Query {
  posts(limit: Int = 10, offset: Int = 0): PostList!
}

type PostList {
  items: [Post!]!
  totalCount: Int!
  hasMore: Boolean!
}

type Post {
  id: ID!
  title: String!
  body: String!
  createdAt: String!
}
const resolvers = {
  Query: {
    posts: async (_, { limit, offset }, { db }) => {
      const [items, totalCount] = await Promise.all([
        db.posts.findMany({
          take: limit,
          skip: offset,
          orderBy: { createdAt: 'desc' },
        }),
        db.posts.count(),
      ]);

      return {
        items,
        totalCount,
        hasMore: offset + items.length < totalCount,
      };
    },
  },
};

The client fetches pages by incrementing offset:

# Page 1
query { posts(limit: 10, offset: 0) { items { id title } totalCount hasMore } }

# Page 2
query { posts(limit: 10, offset: 10) { items { id title } totalCount hasMore } }

# Page 5
query { posts(limit: 10, offset: 40) { items { id title } totalCount hasMore } }

Advantages: Simple to understand and implement. Supports jumping to arbitrary pages (“go to page 5”). Easy to calculate total page count.

Problems: Offset pagination breaks when data changes between requests. If a new post is inserted while the user is on page 1, page 2 will contain a duplicate of the last item from page 1. Deleted items cause skipped records. Large offsets are also slow — the database must count through all skipped rows.

Cursor-Based Pagination

Cursor pagination uses an opaque pointer (cursor) to mark the position in the dataset. Instead of “skip 40 rows,” it says “give me 10 rows after this specific record”:

type Query {
  posts(first: Int = 10, after: String): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

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

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

The cursor typically encodes the sort field value. Base64 encoding makes it opaque to clients:

function encodeCursor(value) {
  return Buffer.from(JSON.stringify(value)).toString('base64');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
}

const resolvers = {
  Query: {
    posts: async (_, { first = 10, after }, { db }) => {
      const where = {};

      if (after) {
        const { createdAt, id } = decodeCursor(after);
        where.OR = [
          { createdAt: { lt: new Date(createdAt) } },
          {
            createdAt: { equals: new Date(createdAt) },
            id: { lt: id },
          },
        ];
      }

      // Fetch one extra to determine hasNextPage
      const items = await db.posts.findMany({
        where,
        take: first + 1,
        orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
      });

      const hasNextPage = items.length > first;
      const edges = items.slice(0, first).map((item) => ({
        cursor: encodeCursor({ createdAt: item.createdAt, id: item.id }),
        node: item,
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!after,
          startCursor: edges.length > 0 ? edges[0].cursor : null,
          endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
        },
        totalCount: await db.posts.count(),
      };
    },
  },
};

The client paginates by passing the endCursor from the previous response:

# First page
query {
  posts(first: 10) {
    edges {
      cursor
      node { id title createdAt }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

# Next page -- use endCursor from previous response
query {
  posts(first: 10, after: "eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTA3VDEwOjAwOjAwLjAwMFoiLCJpZCI6IjQyIn0=") {
    edges {
      cursor
      node { id title createdAt }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Advantages: Stable results even when data changes. No duplicate or skipped records. Efficient database queries using indexed WHERE clauses instead of OFFSET.

Disadvantages: Cannot jump to an arbitrary page. The total page count requires an extra query. More complex to implement.

Bidirectional Cursor Pagination

Support paginating both forward and backward:

type Query {
  posts(
    first: Int
    after: String
    last: Int
    before: String
  ): PostConnection!
}
const resolvers = {
  Query: {
    posts: async (_, { first, after, last, before }, { db }) => {
      if (first && last) {
        throw new GraphQLError('Cannot use both first and last');
      }

      const isForward = !!first || !last;
      const limit = first || last || 10;
      const cursor = after || before;

      let where = {};
      let orderDirection = isForward ? 'desc' : 'asc';

      if (cursor) {
        const decoded = decodeCursor(cursor);
        const operator = isForward ? 'lt' : 'gt';
        where.OR = [
          { createdAt: { [operator]: new Date(decoded.createdAt) } },
          {
            createdAt: { equals: new Date(decoded.createdAt) },
            id: { [operator]: decoded.id },
          },
        ];
      }

      let items = await db.posts.findMany({
        where,
        take: limit + 1,
        orderBy: [
          { createdAt: orderDirection },
          { id: orderDirection },
        ],
      });

      const hasMore = items.length > limit;
      items = items.slice(0, limit);

      // Reverse results for backward pagination so order is consistent
      if (!isForward) {
        items.reverse();
      }

      const edges = items.map((item) => ({
        cursor: encodeCursor({ createdAt: item.createdAt, id: item.id }),
        node: item,
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage: isForward ? hasMore : !!before,
          hasPreviousPage: isForward ? !!after : hasMore,
          startCursor: edges[0]?.cursor || null,
          endCursor: edges[edges.length - 1]?.cursor || null,
        },
      };
    },
  },
};

Implementing Infinite Scroll with Apollo Client

Infinite scroll loads more data as the user scrolls down. Combine fetchMore with cursor pagination:

import { useQuery, gql } from '@apollo/client';
import { useRef, useCallback, useEffect } from 'react';

const GET_POSTS = gql`
  query GetPosts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      edges {
        cursor
        node {
          id
          title
          body
          createdAt
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

function PostFeed() {
  const { data, loading, fetchMore } = useQuery(GET_POSTS, {
    variables: { first: 10 },
  });

  const observer = useRef(null);

  const lastPostRef = useCallback(
    (node) => {
      if (loading) return;
      if (observer.current) observer.current.disconnect();

      observer.current = new IntersectionObserver((entries) => {
        if (
          entries[0].isIntersecting &&
          data?.posts.pageInfo.hasNextPage
        ) {
          fetchMore({
            variables: {
              first: 10,
              after: data.posts.pageInfo.endCursor,
            },
          });
        }
      });

      if (node) observer.current.observe(node);
    },
    [loading, data, fetchMore]
  );

  if (!data) return <p>Loading...</p>;

  return (
    <div>
      {data.posts.edges.map((edge, index) => {
        const isLast = index === data.posts.edges.length - 1;
        return (
          <article
            key={edge.node.id}
            ref={isLast ? lastPostRef : null}
          >
            <h2>{edge.node.title}</h2>
            <p>{edge.node.body}</p>
          </article>
        );
      })}
      {loading && <p>Loading more...</p>}
    </div>
  );
}

Configure the cache to merge paginated results:

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          keyArgs: false,  // All posts queries share the same cache entry
          merge(existing, incoming) {
            if (!existing) return incoming;

            return {
              ...incoming,
              edges: [...existing.edges, ...incoming.edges],
            };
          },
        },
      },
    },
  },
});

Numbered Page Navigation

If you need traditional page numbers (page 1, 2, 3…) while using cursors internally, precompute cursors for page boundaries:

type Query {
  posts(page: Int = 1, pageSize: Int = 10): PaginatedPosts!
}

type PaginatedPosts {
  items: [Post!]!
  currentPage: Int!
  totalPages: Int!
  totalCount: Int!
}
const resolvers = {
  Query: {
    posts: async (_, { page, pageSize }, { db }) => {
      const offset = (page - 1) * pageSize;

      const [items, totalCount] = await Promise.all([
        db.posts.findMany({
          skip: offset,
          take: pageSize,
          orderBy: { createdAt: 'desc' },
        }),
        db.posts.count(),
      ]);

      return {
        items,
        currentPage: page,
        totalPages: Math.ceil(totalCount / pageSize),
        totalCount,
      };
    },
  },
};

This uses offset under the hood, which is acceptable when data changes infrequently and the dataset is small enough that large offsets are not a performance problem.

Filtering with Pagination

Combine filters with cursor pagination by including filter values in keyArgs:

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          keyArgs: ['category', 'authorId', 'sortBy'],
          merge(existing, incoming) {
            if (!existing) return incoming;
            return {
              ...incoming,
              edges: [...existing.edges, ...incoming.edges],
            };
          },
        },
      },
    },
  },
});
query {
  posts(
    first: 10
    after: $cursor
    category: "TECH"
    authorId: "42"
    sortBy: NEWEST
  ) {
    edges {
      node { id title }
    }
    pageInfo { hasNextPage endCursor }
  }
}

The keyArgs configuration tells Apollo to maintain separate caches for different filter combinations. Posts filtered by “TECH” are cached separately from posts filtered by “SPORTS.”

Choosing the Right Pattern

PatternBest ForAvoid When
OffsetAdmin panels, small datasets, page number navigationReal-time feeds, large datasets, frequently changing data
Cursor (forward only)Feeds, timelines, infinite scrollNeed to jump to arbitrary pages
Cursor (bidirectional)Chat history, log viewers, any scroll-back scenarioSimple lists where forward-only suffices
Relay connectionsStandardized API, codegen compatibility, relay clientsQuick prototypes, simple use cases

Start with cursor-based Relay connections if you are building a new API. The upfront cost is minimal, the pattern is standardized, and it scales gracefully. Use offset pagination only when you specifically need numbered page navigation and can accept its limitations.

Progress is saved locally to your browser.