🌐 Detecting your location…
📢 Advertisement — Configure AdSense in Appearance → Customize → AdSense Settings

How to Implement Pagination in a REST API: Complete 2026 Guide

⏱️5 min read  ·  932 words

Pagination is essential for any API that returns lists — without it, a query returning millions of rows crashes your server and overwhelms clients. But there are several pagination strategies with very different performance characteristics. This guide covers them all and when to use each.

Why Pagination Matters

  • Performance: Returning all rows is slow and memory-intensive
  • Bandwidth: Clients don’t need thousands of records at once
  • Database load: Bounded queries protect your database
  • User experience: Loading data in pages is faster and cleaner

Strategy 1: Offset/Limit Pagination (Simple)

-- The classic approach: OFFSET and LIMIT
SELECT * FROM products
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;   -- page 3 (skip 40, take 20)
// Express endpoint
app.get('/products', async (req, res) => {
  const page  = parseInt(req.query.page)  || 1;
  const limit = parseInt(req.query.limit) || 20;
  const offset = (page - 1) * limit;

  const products = await db.query(
    'SELECT * FROM products ORDER BY created_at DESC LIMIT $1 OFFSET $2',
    [limit, offset]
  );
  const total = await db.query('SELECT COUNT(*) FROM products');

  res.json({
    data: products.rows,
    pagination: {
      page,
      limit,
      total: total.rows[0].count,
      totalPages: Math.ceil(total.rows[0].count / limit),
    }
  });
});

Pros: Simple, supports jumping to any page, shows total count.
Cons: Slow on large offsets (the database still scans all skipped rows), and results can shift if data changes between requests.

Strategy 2: Cursor Pagination (Scalable)

Instead of an offset, use a “cursor” (usually the last item’s ID or timestamp) to fetch the next page. This scales to millions of rows because the database jumps directly to the position:

-- Fetch items AFTER a cursor (much faster than large OFFSET)
SELECT * FROM products
WHERE created_at < $1   -- cursor = last item's created_at
ORDER BY created_at DESC
LIMIT 20;
app.get('/products', async (req, res) => {
  const limit  = parseInt(req.query.limit) || 20;
  const cursor = req.query.cursor;   // the last item's timestamp/id

  let query, params;
  if (cursor) {
    query = 'SELECT * FROM products WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2';
    params = [cursor, limit + 1];   // fetch one extra to check for more
  } else {
    query = 'SELECT * FROM products ORDER BY created_at DESC LIMIT $1';
    params = [limit + 1];
  }

  const result = await db.query(query, params);
  const hasMore = result.rows.length > limit;
  const items = hasMore ? result.rows.slice(0, limit) : result.rows;
  const nextCursor = hasMore ? items[items.length - 1].created_at : null;

  res.json({
    data: items,
    pagination: { nextCursor, hasMore }
  });
});

Pros: Fast at any scale (no offset scanning), stable results even as data changes.
Cons: Can't jump to arbitrary pages, no total page count, slightly more complex.

Strategy 3: Keyset Pagination (Best Performance)

-- Keyset uses a unique, ordered column (often id) for precise positioning
SELECT * FROM products
WHERE (created_at, id) < ($1, $2)   -- composite cursor handles ties
ORDER BY created_at DESC, id DESC
LIMIT 20;

Keyset pagination uses a composite cursor (like created_at + id) to handle rows with identical timestamps. It's the most robust high-performance approach, used by APIs serving huge datasets.

Comparison: Which to Use

Strategy Best For Scale
Offset/Limit Small datasets, admin UIs needing page numbers Small-medium
Cursor Feeds, infinite scroll, large datasets Large
Keyset Massive datasets, high-performance needs Very large

Consistent Response Format

// Offset-based response
{
  "data": [ /* items */ ],
  "pagination": {
    "page": 3,
    "limit": 20,
    "total": 1543,
    "totalPages": 78
  }
}

// Cursor-based response
{
  "data": [ /* items */ ],
  "pagination": {
    "nextCursor": "2026-07-20T10:30:00Z",
    "hasMore": true
  }
}

Performance Tips

  • Index your sort column: Pagination queries need an index on the ORDER BY column, or they're slow
  • Avoid COUNT(*) on huge tables: Counting all rows is expensive — cursor pagination avoids needing a total count
  • Cap the limit: Enforce a maximum page size (e.g., 100) so clients can't request everything
  • Use cursor/keyset for large data: Offset pagination degrades badly on large offsets — the database scans all skipped rows
  • Fetch limit+1 to detect "hasMore": Request one extra row to know if there's a next page without a separate count

Frequently Asked Questions

Q: Offset or cursor pagination?
A: Offset for small datasets and admin UIs that need page numbers and jumping to specific pages. Cursor for feeds, infinite scroll, and large datasets where performance and stability matter. Cursor scales far better but can't jump to arbitrary pages.

Q: Why is my offset pagination slow?
A: OFFSET 100000 makes the database scan and discard 100,000 rows before returning your page — increasingly slow as the offset grows. Switch to cursor or keyset pagination, which jump directly to the position using an indexed column.

Q: How do I handle data changing between pages?
A: Offset pagination can skip or duplicate items if rows are added/removed between requests. Cursor pagination is stable because it anchors to a specific item's position, not a numeric offset. Use cursor pagination when data changes frequently.

Q: Do I need to return a total count?
A: For offset pagination with page numbers, usually yes. But COUNT(*) on large tables is expensive. Cursor pagination avoids it entirely (just "hasMore"). If you need approximate counts on huge tables, consider cached or estimated counts.

Q: What's a good default and maximum page size?
A: 20-25 as a default, with a maximum of 100 enforced server-side. This balances response size and number of requests. Always cap the limit so clients can't request unbounded data with ?limit=999999.

Conclusion

Proper pagination is essential for any list-returning API. Use offset/limit for small datasets and admin UIs needing page numbers, cursor pagination for feeds and large datasets, and keyset pagination for massive high-performance needs. Offset is simplest but degrades on large offsets; cursor and keyset scale to millions of rows by jumping directly to position using indexed columns. Index your sort column, cap the page size, fetch limit+1 to detect more pages, and return a consistent response format. Choosing the right strategy for your data scale keeps your API fast and your database healthy as your data grows.

✍️ Leave a Comment

Your email address will not be published. Required fields are marked *

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা