How Should You Design API Pagination for Millions of Records?

Offset-based pagination breaks at scale. Learn cursor-based pagination, keyset pagination, and how Modern PetstoreAPI implements efficient pagination for large datasets.

Ashley Innocent

Ashley Innocent

13 March 2026

How Should You Design API Pagination for Millions of Records?

Apidog for Enterprise

On-Premises Deploy

SSO & RBAC

SOC 2 Compliant

Explore Apidog Enterprise

TL;DR

For large datasets, use cursor-based or keyset pagination instead of offset-based pagination. Offset pagination (?page=1&limit=20) performs poorly with millions of records and allows data inconsistency. Modern PetstoreAPI implements cursor-based pagination with opaque tokens and HATEOAS links for efficient, consistent results.

Introduction

Your API returns a list of pets. You have 10 million pets in the database. A client requests GET /pets?page=500000&limit=20. Your database executes OFFSET 10000000 LIMIT 20. The query takes 30 seconds. Your API times out.

This is the offset pagination problem. It works fine for small datasets but breaks at scale. The database must scan millions of rows to reach the offset, even though you only return 20 results.

The old Swagger Petstore doesn’t address pagination at all. Modern PetstoreAPI implements cursor-based pagination that scales to millions of records with consistent performance.

💡
If you’re building or testing REST APIs, Apidog helps you test pagination behavior, validate response formats, and ensure your API handles large datasets correctly. You can simulate pagination scenarios, test edge cases, and verify performance.
button

In this guide, you’ll learn why offset pagination fails, how cursor-based pagination works, and how Modern PetstoreAPI implements efficient pagination.

Why Offset Pagination Fails at Scale

Offset pagination is the most common approach, but it has serious problems.

How Offset Pagination Works

GET /pets?page=1&limit=20    → OFFSET 0 LIMIT 20
GET /pets?page=2&limit=20    → OFFSET 20 LIMIT 20
GET /pets?page=3&limit=20    → OFFSET 40 LIMIT 20

The database skips offset rows and returns limit rows.

Problem 1: Performance Degrades with Page Number

Page 1:

SELECT * FROM pets OFFSET 0 LIMIT 20;
-- Fast: scans 20 rows

Page 1000:

SELECT * FROM pets OFFSET 20000 LIMIT 20;
-- Slow: scans 20,020 rows, returns 20

Page 500,000:

SELECT * FROM pets OFFSET 10000000 LIMIT 20;
-- Very slow: scans 10,000,020 rows, returns 20

The database must scan all rows up to the offset, even though it discards them. Performance degrades linearly with page number.

Problem 2: Inconsistent Results

While a client pages through results, data changes:

Request 1:

GET /pets?page=1&limit=2
Returns: [Pet A, Pet B]

Someone adds Pet Z (sorts first alphabetically)

Request 2:

GET /pets?page=2&limit=2
Returns: [Pet B, Pet C]  ← Pet B appears twice!

Pet B appeared on both pages because a new pet was inserted. Conversely, pets can be skipped if deletions occur.

Problem 3: Deep Pagination Is Expensive

Users rarely go beyond page 10. But if your API allows ?page=1000000, you must handle it. Deep pagination queries are expensive and can be used for denial-of-service attacks.

When Offset Pagination Is Acceptable

Offset pagination works fine for:

For public APIs or large datasets, use cursor-based pagination.

Cursor-Based Pagination Explained

Cursor-based pagination uses an opaque token to mark position in the result set.

How It Works

Request 1:

GET /pets?limit=20

Response 1:

{
  "data": [...],
  "pagination": {
    "nextCursor": "eyJpZCI6IjAxOWI0MTMyLTcwYWEtNzY0Zi1iMzE1LWUyODAzZDg4MmEyNCJ9",
    "hasMore": true
  }
}

Request 2:

GET /pets?cursor=eyJpZCI6IjAxOWI0MTMyLTcwYWEtNzY0Zi1iMzE1LWUyODAzZDg4MmEyNCJ9&limit=20

The cursor is an opaque token (usually base64-encoded) that encodes the position. The client doesn’t parse it—just passes it back.

Benefits

1. Consistent Performance

The database uses an index to find the cursor position directly:

SELECT * FROM pets
WHERE id > '019b4132-70aa-764f-b315-e2803d882a24'
ORDER BY id
LIMIT 20;

This query is fast regardless of position in the dataset. It uses an index seek, not a scan.

2. Consistent Results

Cursors are stable. If data changes between requests, you still get consistent results. New records don’t cause duplicates or skips.

3. No Deep Pagination Attacks

Clients can’t jump to arbitrary positions. They must page sequentially, which limits abuse.

Cursor Format

Cursors are typically base64-encoded JSON:

// Decoded cursor
{
  "id": "019b4132-70aa-764f-b315-e2803d882a24",
  "createdAt": "2026-03-13T10:30:00Z"
}

The cursor contains enough information to resume pagination. For Modern PetstoreAPI, this includes the resource ID and sort field.

Keyset Pagination for Sorted Data

Keyset pagination is a variant of cursor-based pagination for sorted data.

How It Works

Instead of an opaque cursor, you use the last value from the previous page:

Request 1:

GET /pets?limit=20&sortBy=createdAt

Response 1:

{
  "data": [
    {"id": "...", "createdAt": "2026-03-13T10:00:00Z"},
    ...
    {"id": "...", "createdAt": "2026-03-13T10:30:00Z"}
  ]
}

Request 2:

GET /pets?limit=20&sortBy=createdAt&after=2026-03-13T10:30:00Z

The after parameter uses the last createdAt value from the previous page.

SQL Query

SELECT * FROM pets
WHERE created_at > '2026-03-13T10:30:00Z'
ORDER BY created_at
LIMIT 20;

This is efficient because it uses an index on created_at.

When to Use Keyset Pagination

Modern PetstoreAPI uses cursor-based pagination by default but supports keyset pagination for time-series data.

How Modern PetstoreAPI Implements Pagination

Modern PetstoreAPI uses cursor-based pagination with HATEOAS links.

Request Format

GET /pets?limit=20
GET /pets?cursor={token}&limit=20

Parameters:

Response Format

{
  "data": [
    {
      "id": "019b4132-70aa-764f-b315-e2803d882a24",
      "name": "Fluffy",
      "species": "CAT"
    }
  ],
  "pagination": {
    "limit": 20,
    "hasMore": true,
    "nextCursor": "eyJpZCI6IjAxOWI0MTMyLTcwYWEtNzY0Zi1iMzE1LWUyODAzZDg4MmEyNCJ9"
  },
  "links": {
    "self": "https://petstoreapi.com/pets?limit=20",
    "next": "https://petstoreapi.com/pets?cursor=eyJpZCI6IjAxOWI0MTMyLTcwYWEtNzY0Zi1iMzE1LWUyODAzZDg4MmEyNCJ9&limit=20"
  }
}

Key Features

1. Opaque Cursors

Cursors are base64-encoded. Clients don’t parse them.

2. HATEOAS Links

The links object provides ready-to-use URLs. Clients don’t need to construct pagination URLs.

3. hasMore Flag

Indicates whether more results exist. Clients know when to stop paging.

4. Limit Validation

Maximum limit is 100. Prevents clients from requesting huge pages.

See the Modern PetstoreAPI pagination documentation for complete details.

Pagination Response Format

Modern PetstoreAPI wraps paginated responses in a consistent structure.

Collection Wrapper

{
  "data": [...],
  "pagination": {...},
  "links": {...}
}

Why wrap collections?

  1. Extensibility - Can add metadata without breaking clients
  2. Consistency - All paginated endpoints use the same format
  3. HATEOAS - Links guide clients through pagination

Pagination Metadata

"pagination": {
  "limit": 20,
  "hasMore": true,
  "nextCursor": "...",
  "totalCount": 1000  // Optional, expensive to compute
}

totalCount is optional because computing it is expensive for large datasets. Most clients don’t need it.

Testing Pagination with Apidog

Apidog helps you test pagination behavior comprehensively.

Test Scenarios

1. First Page

GET /pets?limit=20
Expect: 20 results, hasMore=true, nextCursor present

2. Subsequent Pages

GET /pets?cursor={token}&limit=20
Expect: 20 results, hasMore=true/false, nextCursor present/absent

3. Last Page

GET /pets?cursor={lastToken}&limit=20
Expect: < 20 results, hasMore=false, no nextCursor

4. Empty Results

GET /pets?status=NONEXISTENT&limit=20
Expect: 0 results, hasMore=false, no nextCursor

5. Limit Validation

GET /pets?limit=1000
Expect: 400 Bad Request (exceeds max limit)

Apidog Test Configuration

// Test: Pagination structure
pm.test("Response has pagination", () => {
  pm.expect(pm.response.json()).to.have.property('pagination');
  pm.expect(pm.response.json().pagination).to.have.property('hasMore');
});

// Test: HATEOAS links
pm.test("Response has links", () => {
  const links = pm.response.json().links;
  pm.expect(links).to.have.property('self');
  if (pm.response.json().pagination.hasMore) {
    pm.expect(links).to.have.property('next');
  }
});

Choosing the Right Pagination Strategy

Different strategies fit different use cases.

Offset Pagination

Use when:

Don’t use when:

Cursor-Based Pagination

Use when:

Don’t use when:

Keyset Pagination

Use when:

Don’t use when:

Modern PetstoreAPI recommendation: Use cursor-based pagination for public APIs and large datasets.

Conclusion

Pagination is critical for APIs that return large datasets. Offset pagination is simple but doesn’t scale. Cursor-based pagination provides consistent performance and reliable results for millions of records.

Modern PetstoreAPI implements cursor-based pagination with opaque tokens, HATEOAS links, and proper metadata. This design scales efficiently and provides a great developer experience.

Test your pagination implementation with Apidog to ensure it handles edge cases, validates limits, and returns consistent results.

Key takeaways:

button

FAQ

Why not just return all results without pagination?

Returning millions of records in one response causes memory issues, slow network transfer, and poor user experience. Pagination is essential for large datasets.

Can clients jump to a specific page with cursor pagination?

No, cursor pagination requires sequential access. If random access is needed, consider offset pagination for small datasets or implement search/filtering instead.

How do I handle pagination with filtering?

Include filter parameters in pagination requests: GET /pets?status=AVAILABLE&cursor={token}&limit=20. The cursor encodes both position and filter state.

Should I include total count in pagination responses?

Only if clients need it and your dataset is small. Computing total count is expensive for large datasets (requires a separate COUNT query).

How do I implement cursor pagination in SQL?

Use a WHERE clause with the cursor value: SELECT * FROM pets WHERE id > ? ORDER BY id LIMIT 20. Ensure you have an index on the sort column.

What if my cursor tokens become invalid?

Return 400 Bad Request with an error message. Cursors can become invalid if data is deleted or the pagination state expires.

How long should cursors remain valid?

Modern PetstoreAPI cursors are valid indefinitely as long as the referenced resource exists. Some APIs expire cursors after 24 hours.

Can I use cursor pagination with multiple sort fields?

Yes, but the cursor must encode all sort fields. This makes cursors more complex. Consider using a single composite sort key instead.

Explore more

Postman Collection Runner Restrictions: What Changed and How to Work Around It

Postman Collection Runner Restrictions: What Changed and How to Work Around It

Postman restricted Collection Runner on the free tier in 2026, breaking CI/CD workflows. Learn what changed, workarounds, and how Apidog's runner has no limits.

9 June 2026

How to Recover Postman Collections After Being Locked Out

How to Recover Postman Collections After Being Locked Out

Lost access to your Postman collections after the free plan change? Step-by-step recovery guide: local cache, API export, and migrating to Apidog safely.

9 June 2026

How to Share Postman Collections Without Upgrading to Team Plan

How to Share Postman Collections Without Upgrading to Team Plan

Share Postman collections on the free tier without paying $19/user/month. Export JSON, public workspaces, Git sync, and free Apidog collaboration explained.

9 June 2026

Practice API Design-first in Apidog

Discover an easier way to build and use APIs