GraphQL pagination is one of those problems that looks simple until the catalog gets large, the filters get messy, and product wants “infinite scroll” on top of a sorting model that was never designed for it. The right GraphQL pagination pattern keeps the API predictable, the database honest, and the client from re-requesting the same rows forever.

This matters because pagination is not just a UI concern. It is a load pattern, a consistency problem, and a contract between your API and your database. Get it wrong, and you ship duplicate rows, missing rows, slow queries, and awkward support tickets.

For related infrastructure work, see Engineering High-Performance APIs with GraphQL, Optimizing PostgreSQL Query Performance, and API Versioning Strategy for Breaking Changes.

GraphQL Pagination Basics That Hold Up

Good GraphQL pagination starts with one decision: what are you optimizing for? If the answer is “stable navigation through a changing dataset,” then cursor-based pagination is usually the right default. If the answer is “simple admin tooling over a small, mostly static table,” offset pagination can be acceptable. The mistake is pretending those are the same problem.

The Relay-style connection model remains the cleanest fit for serious APIs because it gives you a shape clients can reason about: edges, nodes, pageInfo, and opaque cursors. That structure forces you to think about ordering and boundaries instead of pretending pages are just numbered slices. A page number is a UI metaphor. A cursor is a contract.

A common shape looks like this:

type Query {
  products(first: Int!, after: String, filter: ProductFilter): ProductConnection!
}

type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type ProductEdge {
  cursor: String!
  node: Product!
}

That schema does not solve everything. It only makes the next set of trade-offs explicit. For example, totalCount can be expensive on wide tables, especially when filters involve joins or partial indexes. In some systems, it is better to omit it or make it approximate. Senior teams stop asking “can we expose everything?” and start asking “what does this endpoint need to promise?”

In practice, the best GraphQL pagination shape is the one that survives product changes. If the client later wants “load more,” “jump to next unavailable item,” or “restore scroll position,” cursor pagination has room to grow. If you start with page numbers, you usually end up rebuilding the endpoint later.

For teams building larger API surfaces, this is also where boundary discipline matters. I often pair this work with a clean service boundary and a small contract review. That is the kind of work we handle in a Sprint, Build, or Fractional engagement when the pagination problem is really an API shape problem.

Cursor Pagination for Large Catalogs

Cursor pagination works because it anchors the next page to a record boundary instead of an arbitrary offset. That matters when rows are inserted, deleted, or reordered while users are browsing. A user on page three should not suddenly see duplicates just because a background import added 2,000 new items.

The cursor itself should be opaque to clients. Internally, it often encodes the sort key and a tie-breaker, usually a primary key. For example, if you sort by created_at DESC, id DESC, your cursor might encode both values. That tie-breaker is not optional. Without it, two rows with the same timestamp can flip positions between requests.

Here is the pattern I recommend most often:

WHERE
  (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3 + 1

The extra row tells you whether another page exists. That is a small detail, but it prevents a second query in many cases. It also keeps the database work bounded. With the right composite index, PostgreSQL can satisfy this query cleanly:

CREATE INDEX CONCURRENTLY idx_products_created_id_desc
ON products (created_at DESC, id DESC);

Where this pattern fails is when the sort key is not stable or not unique enough. Sorting by updated_at on a catalog that changes constantly can produce confusing jumps. Sorting by a nullable field can create edge cases unless you define null ordering carefully. Sorting by an aggregated field is worse; now every page turn may depend on a join or subquery that gets slower as the dataset grows.

I have seen teams try to make cursors “smarter” by stuffing too much state into them: user role, filter hash, locale, A/B bucket, and three timestamps. That is a smell. Keep cursors small, deterministic, and rebuildable. If the cursor cannot be validated on the server without guesswork, it will eventually break when the surrounding query changes.

If you want a practical comparator, think of it this way:

  • Offset pagination: easy to understand, fragile at scale.
  • Cursor pagination: slightly more work, much safer under writes.
  • Keyset pagination: the database-native form of cursor pagination.

That last point matters. Cursor pagination is not a GraphQL invention. It is a database access pattern with a GraphQL wrapper. The API should reflect the database reality, not hide it.

When Offset Pagination Fails

Offset pagination looks neat because it maps to human language: page 1, page 2, page 3. It also maps cleanly to SQL. The problem is that it maps cleanly only while the dataset is small and quiet. Once rows start moving, offset becomes a moving target.

Suppose a catalog has 5 million products and the client asks for page 4000 with a page size of 25. The database still has to count or skip 99,975 rows before it can return the next 25. On some indexes that is tolerable. On others it becomes expensive enough to show up in latency graphs and query plans. Even when the query is fast, the semantics are wrong under concurrent writes.

Here is the failure mode: user A loads page 2. User B inserts a new row at the top. User A loads page 3. The second page now overlaps the first, or skips a row entirely. That is not a rare bug. It is the expected result of offset pagination on a mutable dataset.

Offset also gets ugly when filters change. A user narrows a catalog by brand, price, or availability. The “page number” is now tied to a different result set, but the client may still be carrying page state from the previous filter. You can patch around this, but the patches keep growing. That is usually the moment to stop and switch to a cursor model.

There are still places where offset is fine. Admin panels with small tables. Export screens. Internal tools where consistency is less important than speed of implementation. The key is to make the trade-off consciously, not by default. I prefer a simple rule:

  1. If the dataset is mutable and user-facing, use cursors.
  2. If the dataset is small and mostly static, offset can be acceptable.
  3. If you need stable export jobs, use batch boundaries instead of pagination at all.

That last case matters. Sometimes pagination is the wrong abstraction. If a job must process every row exactly once, a paginated API is not the right tool. Use a cursor in the worker itself, or better, a batch processing pipeline with checkpointing. We covered adjacent trade-offs in Batch Processing vs Real-Time: When to Choose Each and Bulk Data Migrations Without the Downtime.

One more subtle failure: offset pagination can hide performance issues until a product launch. A table with 50,000 rows behaves fine in staging. At 5 million rows, the same endpoint starts timing out. That is why senior teams read the query plan before they read the code review. The database is the real customer.

Sorting, Filters, and Consistency

GraphQL pagination gets complicated the moment you add filters, because the cursor must be valid for the current sort order and filter set. If a user filters products by category and price, the cursor for one filter set should not accidentally resume another. That is where many implementations become unreliable.

The safest pattern is to bind the cursor to the exact query shape. That usually means encoding enough information to detect mismatches server-side. A cursor can be opaque to clients and still be validated against the current arguments. If the filter set changes, the server rejects the cursor and asks the client to restart from the beginning.

This is also where ordering rules need to be explicit. Every paginated list needs a deterministic sort. If the business says “sort by popularity,” define what popularity means. Is it purchase count over 30 days? Lifetime views? Weighted score? If the value changes frequently, you may need a materialized ranking table instead of computing it live on every request.

For filtered pagination, composite indexes matter. A common pattern for a catalog endpoint is something like:

CREATE INDEX CONCURRENTLY idx_products_category_price_created_id
ON products (category_id, price, created_at DESC, id DESC);

That index can support a narrow set of filters and a stable sort. But indexes are not free. Every additional index slows writes and consumes memory. The right answer is not “index everything.” The right answer is “index the access patterns you can prove.”

Consistency also matters at the GraphQL layer. If the resolver fetches a page of IDs and then hydrates each record separately, the page can drift between steps. That is one reason GraphQL pagination bugs often look like database bugs. The API shape is fine, but the resolver is doing too many round trips. Batch the lookup. Keep the snapshot coherent.

When teams are serious about this, I like to walk through a decision matrix:

  • Stable sort + mutable rows: cursor pagination.
  • Stable sort + static rows: offset may be fine.
  • Complex filter + expensive count: cursor pagination, no totalCount or approximate total.
  • Analytics browse view: precomputed slices or reporting tables.

If you are also shaping the front end, the client contract matters. Next.js pages that cache list state need to treat the cursor as part of the URL or state key. Otherwise, users navigate back and see the wrong page fragment. That kind of bug feels small until it touches conversion. For adjacent frontend work, see Nextjs Hydration Errors: Debugging Production React SSR.

Implementation Patterns That Ship Cleanly

A clean implementation usually has three parts: a stable query, an opaque cursor codec, and a resolver that can fetch one extra row. That is enough for most systems. The rest is packaging.

In Node.js, a resolver might decode the cursor, validate the filters, and call a repository method that uses keyset pagination. In Go, the same idea is even simpler: pass the boundary values directly into a SQL query builder. In either case, keep the pagination logic out of the GraphQL type layer. The schema should describe the contract. The repository should enforce it.

Here is the kind of resolver shape I prefer:

const page = await productRepo.list({
  categoryId,
  after: decodeCursor(args.after),
  limit: args.first,
  sort: 'created_at_desc',
});

return {
  edges: page.items.map(item => ({
    cursor: encodeCursor({ createdAt: item.created_at, id: item.id }),
    node: item,
  })),
  pageInfo: {
    hasNextPage: page.hasNextPage,
    endCursor: page.endCursor,
  },
};

Two details matter here. First, encode the cursor from the same fields you used in the query. Second, do not trust the client to preserve anything important. Cursors are hints, not permissions. If they fail validation, fail clearly.

Testing should cover the failure modes, not just the happy path. I want tests for duplicate rows under concurrent inserts, missing rows after deletes, and invalid cursors with changed filters. If you are working in a team with production traffic, add a load test that exercises deep pagination. A list endpoint that looks fine at 100 rows can behave very differently at 100,000.

Operationally, watch three metrics:

  1. p95 latency for the paginated resolver.
  2. Rows examined per query in the database.
  3. Duplicate or missing item reports from support or QA.

If you see p95 climbing with page depth, the access pattern is wrong. If rows examined grows much faster than page size, the index is wrong. If users report duplicates, the cursor boundary or sort key is unstable. These are not mysterious bugs. They are the shape of the query leaking through the API.

In bigger systems, pagination often sits next to other reliability concerns: rate limiting, caching, and cache invalidation. A cursor-based page cache is usually safer than caching page numbers, because the cursor encodes the boundary. That said, cache keys should include the sort and filter state or you will serve stale slices. Related patterns show up in Cache Invalidation Strategies: When TTL Fails and API Gateway Rate Limiting Strategy for SaaS APIs.

The best GraphQL pagination implementations are boring in the right way. They are deterministic, index-backed, and honest about trade-offs. That is what keeps a catalog usable after the first real surge of traffic, the first data import, and the first product manager who asks for one more sort option.

Pagination bugs are expensive because they hide in plain sight and erode trust one page turn at a time. If you are trying to stabilize an API surface or rework a catalog endpoint, it is worth bringing in a senior engineer who has shipped this before. We take three engagements a quarter by application, and the application takes ten minutes. If the problem is contained, a Sprint can usually pin down the right GraphQL pagination pattern and the database work behind it.