API Pagination Strategies

Choose the right pagination method for your REST API: cursor, offset, keyset, or page-based

Why Pagination Matters

Pagination is essential for REST APIs that return large datasets. Without it, responses become slow, memory-intensive, and unusable for clients. The right pagination strategy depends on your data characteristics and client needs.

  • Reduces response payload size
  • Improves API performance
  • Better user experience
  • Lower server memory usage
  • Enables real-time data handling
GET /api/users?cursor=abc123&limit=10
{
  "data": [...],
  "next_cursor": "xyz789",
  "has_more": true
}

1. Offset/Limit Pagination

The simplest pagination approach using offset and limit parameters. Skip N records and return M records.

Request

GET /api/users?offset=20&limit=10

Response

{
  "data": [
    { "id": 21, "name": "User 21", "email": "user21@example.com" },
    { "id": 22, "name": "User 22", "email": "user22@example.com" },
    // ... 8 more users
  ],
  "offset": 20,
  "limit": 10,
  "total": 150
}

✅ Pros

  • Simple to implement
  • Random access to any page
  • Easy to understand for clients
  • Works with any database

❌ Cons

  • Poor performance on large datasets (O(n) offset)
  • Inconsistent results with real-time data
  • Duplicates/skips when data changes
  • Database must count all skipped rows

SQL Query

SELECT * FROM users
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;  -- Scans 30 rows, returns 10

2. Cursor-Based Pagination

Uses an opaque cursor (often base64-encoded) to mark the position in the dataset. The most robust approach for real-time data and infinite scrolling.

Request

GET /api/users?cursor=eyJpZCI6MTAwfQ&limit=10

Response

{
  "data": [
    { "id": 101, "name": "User 101", "email": "user101@example.com" },
    { "id": 102, "name": "User 102", "email": "user102@example.com" },
    // ... 8 more users
  ],
  "cursors": {
    "next": "eyJpZCI6MTEwfQ",
    "prev": "eyJpZCI6MTAwfQ"
  },
  "has_more": true
}

✅ Pros

  • Consistent results with real-time data
  • Excellent performance (O(1) with index)
  • No duplicates or skipped records
  • Perfect for infinite scroll

❌ Cons

  • No random access (can't jump to page 5)
  • Can't show total count efficiently
  • More complex implementation
  • Cursors can become invalid

Implementation Example (Node.js)

// Encode cursor
function encodeCursor(id) {
  return Buffer.from(JSON.stringify({ id })).toString('base64');
}

// Decode cursor
function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64').toString('utf8'));
}

// Query with cursor
async function getUsers(cursor, limit = 10) {
  let query = 'SELECT * FROM users';
  const params = [];
  
  if (cursor) {
    const { id } = decodeCursor(cursor);
    query += ' WHERE id > $1';
    params.push(id);
  }
  
  query += ' ORDER BY id ASC LIMIT $' + (params.length + 1);
  params.push(limit + 1); // Fetch one extra to check has_more
  
  const rows = await db.query(query, params);
  const hasMore = rows.length > limit;
  const data = hasMore ? rows.slice(0, -1) : rows;
  
  return {
    data,
    cursors: {
      next: hasMore ? encodeCursor(data[data.length - 1].id) : null,
      prev: cursor || null
    },
    has_more: hasMore
  };
}

3. Keyset Pagination

Similar to cursor-based but uses visible column values instead of opaque cursors. Ideal when sorting by a unique, indexed column like ID or timestamp.

Request

GET /api/users?after_id=100&limit=10

Response

{
  "data": [
    { "id": 101, "name": "User 101", "created_at": "2024-01-15T10:30:00Z" },
    { "id": 102, "name": "User 102", "created_at": "2024-01-15T11:00:00Z" },
    // ... 8 more users
  ],
  "after_id": 110,
  "has_more": true
}

✅ Pros

  • Most performant for large datasets
  • Transparent (visible key values)
  • Consistent with real-time data
  • Uses database indexes efficiently

❌ Cons

  • No random access
  • Requires unique, indexed sort column
  • Complex with multiple sort columns
  • Exposes internal IDs

SQL Query (Highly Optimized)

-- Simple keyset on ID
SELECT * FROM users
WHERE id > 100
ORDER BY id ASC
LIMIT 10;

-- Keyset with multiple columns (created_at, id)
SELECT * FROM users
WHERE (created_at, id) > ('2024-01-15T10:30:00Z', 100)
ORDER BY created_at ASC, id ASC
LIMIT 10;

4. Page-Based Pagination

The most user-friendly approach using page numbers. Internally converts to offset/limit but provides a cleaner interface for clients.

Request

GET /api/users?page=2&per_page=10

Response

{
  "data": [
    { "id": 11, "name": "User 11", "email": "user11@example.com" },
    { "id": 12, "name": "User 12", "email": "user12@example.com" },
    // ... 8 more users
  ],
  "page": 2,
  "per_page": 10,
  "total": 150,
  "total_pages": 15
}

✅ Pros

  • Intuitive for end users
  • Easy to build pagination UI
  • Random access to any page
  • Shows total pages

❌ Cons

  • Same performance issues as offset
  • Inconsistent with real-time data
  • Total count can be expensive
  • Not suitable for large datasets

Conversion Formula

// Page to offset conversion
const offset = (page - 1) * per_page;
const limit = per_page;

// Example: page=2, per_page=10
// offset = (2 - 1) * 10 = 10
// SQL: LIMIT 10 OFFSET 10

5. HATEOAS Pagination Links

Following REST principles, include hypermedia links in responses. Clients don't need to construct URLs—they follow the provided links.

Complete HATEOAS Response

{
  "data": [
    {
      "id": 11,
      "name": "User 11",
      "email": "user11@example.com",
      "_links": {
        "self": "/api/users/11",
        "orders": "/api/users/11/orders"
      }
    },
    // ... more users
  ],
  "links": {
    "self": "/api/users?page=2",
    "first": "/api/users?page=1",
    "prev": "/api/users?page=1",
    "next": "/api/users?page=3",
    "last": "/api/users?page=10"
  },
  "meta": {
    "total": 100,
    "per_page": 10,
    "current_page": 2,
    "total_pages": 10,
    "from": 11,
    "to": 20
  }
}
🔗

Link Relations (RFC 8288)

Standard link relation types for pagination:

  • self - Current page
  • first - First page of results
  • prev - Previous page (omit on first page)
  • next - Next page (omit on last page)
  • last - Last page of results

HTTP Link Header Alternative

HTTP/1.1 200 OK
Link: </api/users?page=1>; rel="first",
      </api/users?page=1>; rel="prev",
      </api/users?page=3>; rel="next",
      </api/users?page=10>; rel="last"
X-Total-Count: 100
X-Page: 2
X-Per-Page: 10

6. Comparison: When to Use Each

Strategy Performance Random Access Real-time Safe Best For
Offset/Limit O(n) Yes No Small datasets, admin panels
Cursor-Based O(1) No Yes Social feeds, infinite scroll
Keyset O(1) No Yes Large datasets, logs, analytics
Page-Based O(n) Yes No E-commerce, search results

Decision Guide

📱

Mobile/Infinite Scroll

Use cursor-based pagination. Handles real-time data well and is efficient for "load more" patterns.

🛒

E-commerce/Search

Use page-based pagination. Users expect page numbers and the ability to jump to specific pages.

📊

Analytics/Logs

Use keyset pagination. Handles millions of rows efficiently with time-based sorting.

⚙️

Admin Dashboards

Use offset/limit or page-based. Datasets are usually smaller and random access is helpful.

Quick Reference

// Offset/Limit
GET /api/users?offset=20&limit=10

// Cursor-Based
GET /api/users?cursor=eyJpZCI6MTAwfQ&limit=10

// Keyset
GET /api/users?after_id=100&limit=10

// Page-Based
GET /api/users?page=3&per_page=10

Pagination Best Practices

🔢 Set Reasonable Defaults

✅ Good limit=20 (default), max_limit=100
❌ Bad Allow limit=10000 or no limit

📄 Include Metadata

✅ Good Return total, has_more, next_cursor
❌ Bad Only return data, no pagination info

🔗 Provide Navigation Links

✅ Good Include next, prev, first, last links
❌ Bad Force clients to construct URLs

⚡ Optimize Count Queries

✅ Good Cache total count, use has_more flag
❌ Bad SELECT COUNT(*) on every request