TechLead
Lesson 12 of 20
5 min read
GraphQL

Pagination Strategies

Implement offset-based, cursor-based, and Relay-style pagination in GraphQL for efficient data loading

Why Pagination Matters

Without pagination, a query like posts { title } could return millions of records, destroying performance and user experience. GraphQL supports several pagination strategies, each with different trade-offs for consistency, performance, and complexity.

Offset-Based Pagination

The simplest approach. Use limit and offset to define a window into the data. Familiar to anyone who has used SQL's LIMIT/OFFSET:

type Query {
  posts(limit: Int = 20, offset: Int = 0): PostsResult!
}

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

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

# Page 2
query GetPostsPage2 {
  posts(limit: 10, offset: 10) {
    items { id title }
    totalCount
    hasMore
  }
}
// Resolver for offset-based pagination
const resolvers = {
  Query: {
    posts: async (_: unknown, args: { limit: number; offset: number }, ctx: Context) => {
      const [items, totalCount] = await Promise.all([
        ctx.prisma.post.findMany({
          take: args.limit,
          skip: args.offset,
          orderBy: { createdAt: 'desc' },
        }),
        ctx.prisma.post.count(),
      ]);

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

Offset Pagination: Pros and Cons

  • Pro: Simple to implement and understand
  • Pro: Supports jumping to any page directly
  • Con: Inconsistent results if data changes between requests (items can shift or duplicate)
  • Con: Performance degrades for large offsets (database must scan and skip rows)

Cursor-Based Pagination

Cursor-based pagination uses an opaque cursor (usually a base64-encoded ID or timestamp) to mark the position in the result set. It provides consistent results even when data changes between requests:

type Query {
  posts(first: Int = 20, 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
}

# Usage — first page
query FirstPage {
  posts(first: 10) {
    edges {
      cursor
      node { id title publishedAt }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

# Next page — use endCursor from previous response
query NextPage {
  posts(first: 10, after: "Y3Vyc29yXzEw") {
    edges {
      cursor
      node { id title publishedAt }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
// Cursor-based pagination resolver
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_', '');
}

const resolvers = {
  Query: {
    posts: async (
      _: unknown,
      args: { first: number; after?: string },
      ctx: Context
    ) => {
      const take = Math.min(args.first, 100); // Cap at 100
      const where: any = {};

      if (args.after) {
        const afterId = decodeCursor(args.after);
        const afterPost = await ctx.prisma.post.findUnique({
          where: { id: afterId },
          select: { createdAt: true },
        });
        if (afterPost) {
          where.createdAt = { lt: afterPost.createdAt };
        }
      }

      const posts = await ctx.prisma.post.findMany({
        where,
        take: take + 1, // Fetch one extra to check hasNextPage
        orderBy: { createdAt: 'desc' },
      });

      const hasNextPage = posts.length > take;
      const edges = posts.slice(0, take).map(post => ({
        cursor: encodeCursor(post.id),
        node: post,
      }));

      const totalCount = await ctx.prisma.post.count();

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

React Client Implementation

// Infinite scroll with cursor-based pagination
import { gql, useQuery } from '@apollo/client';

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

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

  const loadMore = () => {
    if (!data?.posts.pageInfo.hasNextPage) return;

    fetchMore({
      variables: {
        after: data.posts.pageInfo.endCursor,
      },
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) return prev;
        return {
          posts: {
            ...fetchMoreResult.posts,
            edges: [...prev.posts.edges, ...fetchMoreResult.posts.edges],
          },
        };
      },
    });
  };

  return (
    <div>
      {data?.posts.edges.map(({ node }: any) => (
        <article key={node.id} className="p-4 border-b">
          <h2 className="font-bold">{node.title}</h2>
          <p className="text-gray-600">{node.excerpt}</p>
        </article>
      ))}
      {data?.posts.pageInfo.hasNextPage && (
        <button onClick={loadMore} disabled={loading} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
          {loading ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Choosing a Pagination Strategy

  • Offset: Best for traditional page numbers (page 1, 2, 3) and admin dashboards
  • Cursor: Best for infinite scroll, real-time feeds, and large datasets
  • Relay Connection: Best when using Relay client or when you need a standardized pagination spec

Continue Learning