Wednesday, September 30, 2026
Cover illustration for “API Pagination Strategies Across REST and GraphQL”
Writing for Technical FoundersAPI Pagination Strategies Across REST and GraphQL

API Pagination Strategies Across REST and GraphQL

Cursor-based pagination beats offset for large datasets and real-time data.

Editor at Large · · 11 min read

Start with the worst option on the table: no pagination at all. A query with no limit tells the database to load every matching row, serialize the whole thing, and ship it over the wire in a single response. It stops working long before you'd expect.

In Node.js backends specifically, this gets ugly fast. Resolvers materialize the entire result set in memory before anything gets returned to the client, which means the event loop and garbage collector take the hit, not just the response clock. Slow responses are the visible symptom. The real cost is a process straining under memory pressure it never needed to carry.

This isn't a theoretical concern dressed up to sound scary. A SQLite-backed demo seeded with 500,000 products produced a single-query response time of 2.817s, and that cost scales linearly with row count Using Pagination to Improve GraphQL Performance Using Pagination to Improve GraphQL Performance.

But "add pagination" isn't a single decision. It's several, stacked on top of each other, and picking the wrong one just reintroduces the same problems wearing a different hat. The fetch-all pattern breaks down under four compounding problems: unbounded results, poor memory characteristics, unpredictable performance under concurrent load, and the ease with which it can be abused unintentionally.

The three strategies and their key differences

Offset and limit pagination is the one everybody learns first. A numeric index tells the database where to start, the client bumps that number by the page size, and off you go to the next page. Page-number pagination is basically the same trick wearing a nicer sweater: swap the raw offset for a page integer, and the database derives the actual offset by multiplying page by page size. Simpler to reason about, functionally identical under the hood.

Cursor-based pagination works differently. Instead of a number, the server hands back an opaque string, often base64-encoded, that marks the last row the client saw. The next request says "give me everything after this," and the server resolves that from the cursor rather than counting. Slack's implementation is a clean example: the cursor string encodes the field name, its value, and the sort logic, all base64-wrapped. That opacity isn't decoration. It stops developers from hard-coding assumptions about what's inside the cursor, and it lets Slack change its internal pagination logic without breaking anyone's integration.

Keyset pagination is cursor's more transparent cousin. Same indexed-seek logic under the hood, but instead of hiding the field value inside an encoded blob, it puts it right in the parameter, something like after_id=100. It's best suited to sequential, time-series-style data where the ordering key is already meaningful and there's no need to hide it.

Offset tells the database how many rows to skip. Cursor and keyset tell it where to seek. Skipping means scanning past everything you're throwing away. Seeking means jumping straight there. That's the whole ballgame.

Where offset pagination fails under real load

The deep-page scan problem is the one that catches teams off guard. Ask for page 1,000 at 10 items per page, and the database has to scan and discard roughly 10,000 rows before it can hand back the ten you actually wanted. That reflects a deeper structural cost. That's the entire mechanism working exactly as designed, and the design just doesn't hold up.

The 500,000-product demo puts numbers on this Using Pagination to Improve GraphQL Performance Using Pagination to Improve GraphQL Performance. Last-page query: over a second Using Pagination to Improve GraphQL Performance Using Pagination to Improve GraphQL Performance. From the client's chair, it's the identical request shape, just a different number in the offset field. On the database side, it's night and day Using Pagination to Improve GraphQL Performance Using Pagination to Improve GraphQL Performance.

Then there's instability, which is arguably worse because it's silent. If a record gets inserted or deleted between two page requests, every offset after that point shifts. Clients end up skipping records they should have seen, or seeing the same one twice. The GraphQL documentation calls this out directly for rapidly changing datasets. Social feeds, notification streams, and anything with a constant drip of new rows produce technically correct and practically useless offset-paginated results, because the pages keep moving underneath the client mid-scroll.

None of this makes offset pagination a bad idea across the board. It's still the right call for static or slowly changing datasets, for interfaces that genuinely need "jump to page N" access, and for smaller datasets where deep-page degradation never materializes. The failure mode here is structural. The fix isn't a smarter offset query. It's picking a different strategy when the dataset outgrows what offset was ever built to handle.

How cursor-based pagination solves consistency and performance at the database level

Cursor and keyset pagination fix the scan at the source, using a compound index (something like one built on created_at and id together) to seek directly to the right starting row. No scanning past ten thousand discarded rows to get there. No full table scan at all.

Three advantages fall out of that directly. Stability comes first: since cursors point to an actual position rather than a row count, insertions and deletions elsewhere in the table don't shift the boundary out from under the client. Consistency at scale rounds it out, since this approach holds up on tables with millions of rows where offset simply gives out.

None of this is free of edge cases. Cursors can go stale, either because the record they point to gets deleted, or because the API enforces a time-based expiration on cursors themselves. Any client relying on cursor pagination needs a defined fallback for that moment, because "cursor no longer valid" is a real response your code has to handle, not a hypothetical.

Cursor pagination only moves forward and backward. That's a genuine UI constraint, not a footnote to skim past. If a product requirement says "users need to type in a page number and land there directly," cursor pagination is fighting that requirement, not serving it.

Even with that caveat, the current consensus leans firmly toward cursor-based pagination as the default for stable, scalable APIs, with offset treated as a transitional or purpose-specific tool rather than the long-term answer. Not "offset is wrong," just "offset is for a smaller job than most teams assign it.""

REST pagination conventions

REST has no formal specification for pagination. No RFC says "thou shalt use starting_after." What exists instead is a set of strong conventions, built by the public APIs of major platforms and adopted widely enough to function as a de facto standard.

Stripe's list endpoints, charges, customers, invoices, all share one shape, accepting limit, starting_after, and ending_before at minimum. That's cursor-based pagination by default for list endpoints, though its search endpoints run a different cursor mechanism using page and next_page. Shopify's Storefront API also leans on cursors for list pagination, and it ties pagination state into the URL itself, so a shopper can click into a product and land back on the exact scroll position they left. That's not an aesthetic choice. Shareable URL state is a functional requirement, and it shapes how the cursor gets designed from the ground up.

Don't mix pagination styles across endpoints in the same API. That's a documented mistake, not a style quibble. Consistency across every list endpoint is a design requirement, full stop, not a nice-to-have that gets traded off when a deadline's tight.

On the response side, a well-built REST pagination payload includes the cursor or offset value, some signal for whether more data exists (a hasMore flag or a Link header does the job), and total record counts where that's cheap to compute without running an expensive count query.

GraphQL's Relay Cursor Connections spec

GraphQL, like REST, ships with no built-in pagination standard baked into the spec. What it has instead is a strong recommendation from its own documentation: use cursor-based pagination with connections and opaque cursors. That recommendation calcified into the Relay Cursor Connections model, which is now close to universal across serious GraphQL implementations.

The GraphQL team's own words on this are refreshingly blunt: "In general, we've found that cursor-based pagination is the most powerful of those designed". Not "one of several equally valid options." The most powerful one.

The spec itself has a handful of moving parts. A Connection type wraps the paginated list, and by convention its name always ends in "Connection". An Edge type wraps each individual item, pairing the actual data (the node) with that item's cursor. A PageInfo type is required by the spec and must expose two non-null booleans, hasNextPage and hasPreviousPage, so clients always know where they stand without guessing https://docs.github.com/en/graphql/guides/using-pagination-in-the-graphql-api. Forward pagination runs on first and after; backward pagination runs on last and before. Ordering is left up to whatever business logic makes sense for the data, the spec doesn't dictate it, but it does insist that ordering stay consistent from one page to the next, or the whole cursor mechanism falls apart.

GitHub's API, Shopify, and Twitter's GraphQL API all build on this pattern, which is a decent chunk of the reason it reads as "the standard" rather than "a standard."

Teams serious about enforcing this shouldn't treat it as a style guide suggestion. Require first or last on every paginated query, statically where possible. Reject unbounded connection requests at the schema or gateway level before they ever touch the resolver. Add a rule asserting that any type ending in "Connection" actually conforms to the spec. Favor connection types over bare lists with pagination arguments bolted on. And keep cursor encoding and decoding stable and opaque, the same discipline Slack applies to its own cursor strings.

GraphQL-specific performance risks that pagination alone does not solve

Pagination handles the outer list. It does nothing for what happens inside each item on that list, and that gap is where GraphQL's most notorious performance problem lives.

The N+1 problem occurs in nested connections. Say a paginated query returns 20 items, and each item triggers its own sub-query to fetch related data Using Pagination to Improve GraphQL Performance. That's 21 round trips to the database for one page. Pagination trimmed the outer set down to a sane size, sure, but the fan-out in the nested connections continued producing extra round trips regardless.

The standard fix is DataLoader, which batches those sub-queries together across a single page instead of firing them one at a time. This isn't an optional add-on to bolt on later if things get slow. It needs to be configured alongside cursor pagination from the start in any production system, treated as part of the same problem rather than a separate concern to revisit.

Query complexity is the second landmine. Nothing stops a client from requesting deeply nested connections that multiply the underlying work several times over, unless something is actively stopping them. Production GraphQL needs complexity budgets enforced at the schema or gateway layer, because "trust the client to be reasonable" is not a strategy but a hope.

And caching deserves an honest mention here, because it's the tradeoff people forget to ask about. Cursor-encoded pages don't play nearly as nicely with caching as offset pages do, since offset pages have predictable URL parameters and cursor-encoded pages are less cache-friendly. Teams need to actually decide, on purpose, whether edge or CDN caching is realistic for their specific cursor scheme, or whether they're leaning on DataLoader's per-request cache and calling it a day. Skipping that decision is deciding by accident. It's just deciding by accident.

Bridging GraphQL pagination to REST backends (the aggregation layer pattern)

A lot of GraphQL servers aren't talking to a database directly. The pattern is a GraphQL server calling one or more REST endpoints to aggregate data, translating GraphQL's cursor arguments (first, after) into equivalent paging parameters passed through to the underlying REST call.

IBM API Connect documents one version of this mapping, translating GraphQL pagination types directly onto REST parameters like PAGE_NUMBER, OFFSET, and NEXT_CURSOR, each with its own setter format for pulling total count or next-cursor data back out of the REST response metadata.

Reliability is the real reason to favor cursor-based pagination in this setup. Sync loops that pull data from an external API on a schedule handle insertions and deletions between pages far more gracefully with cursors than with offsets. An offset-based sync against a frequently-updating HRIS system or inventory feed can quietly produce inconsistent results across pages, records missed, records doubled, and nobody notices until the totals don't add up.

A short checklist earns its keep here. Figure out which pagination style the external REST API actually supports before the GraphQL layer gets designed around a wrong assumption. Build in retry logic with backoff for requests that hit rate limits mid-sync. Decide, ahead of time, what happens when a cursor goes invalid partway through a sync, whether from deletion or expiry. And never expose the REST API's raw offset or page number straight through as the GraphQL cursor. Encode it opaquely instead, so the internal pagination logic can change later without breaking every consumer relying on it.

Choosing the right strategy: a decision map by dataset and use case

Offset or page-number pagination earns its keep when the dataset is small to medium and static or slowly changing, when the interface requires direct "jump to page N" access, or when development speed matters more than deep-pagination performance.

Cursor-based pagination, the Relay spec in GraphQL, starting_after/ending_before in REST, is the right call when the dataset is large or growing without pause, when concurrent writes need to stay consistent across page requests, when the UI pattern is infinite scroll or "load more" rather than numbered pages, or when the API has to perform predictably no matter how deep someone pages.

Keyset pagination fits the narrower lane: sequential, time-series-style datasets, using the same indexed-seek logic as cursor-based pagination but exposing the actual field value in the parameter, such as after_id=100, rather than encoding it.

One rule sits above all of this and overrides every other consideration on the list: whatever strategy gets chosen, apply it the same way across every list endpoint in the API. Mixing strategies endpoint to endpoint is a documented, repeated failure mode in production systems, not a rare misstep. Starting with offset pagination isn't a mistake either, plenty of teams do, and plenty should. Treat it as a decision with an expiration date, one to revisit once the data volume climbs or real-time requirements increase, rather than a default that gets left alone out of habit. And for teams working in GraphQL specifically, the Relay spec holds up best when it's enforced through linting and schema rules, so the cursor pattern is structural rather than just a convention writers might skip.

Sources

  1. Paginating a REST API as a data source - IBM Documentation
  2. Using Pagination to Improve GraphQL Performance
  3. Pagination | GraphQL
  4. Using pagination in the GraphQL API - GitHub Docs
  5. GraphQL Cursor Connections Specification

More in Integration Architecture