API Pagination Deep Dive

If you're building an API that returns lists of data, you'll quickly run into a problem: what happens when that list has thousands or millions of items? You can't return everything at once—it would be slow, memory-intensive, and probably crash someone's browser. That's where pagination comes in. But pagination

TRY NANO BANANA FOR FREE

API Pagination Deep Dive

TRY NANO BANANA FOR FREE
Contents

If you're building an API that returns lists of data, you'll quickly run into a problem: what happens when that list has thousands or millions of items? You can't return everything at once—it would be slow, memory-intensive, and probably crash someone's browser.

That's where pagination comes in. But pagination isn't as simple as it sounds. There are multiple strategies, each with different trade-offs around performance, consistency, and ease of implementation.

In this guide, we'll explore the three main pagination approaches—offset-based, cursor-based, and keyset pagination—and show you how to implement each one effectively.

Why Pagination Matters

Before we dive into the different approaches, let's talk about why pagination is essential:

Performance: Returning 10,000 records in a single response is slow. The database query takes longer, serialization takes longer, network transfer takes longer, and the client has to process more data.

Memory: Large responses consume memory on both the server and client. This can lead to out-of-memory errors, especially on mobile devices or in resource-constrained environments.

User Experience: Users don't want to wait 30 seconds for a response. They want fast, incremental loading that feels responsive.

Cost: Transferring large amounts of data costs money, especially if you're paying for bandwidth or API calls.

Good pagination solves all these problems by breaking large datasets into manageable chunks.

Offset-Based Pagination

This is the simplest and most common pagination strategy. You specify how many items to skip (offset) and how many to return (limit):

GET /api/pets?limit=20&offset=0   # First page
GET /api/pets?limit=20&offset=20  # Second page
GET /api/pets?limit=20&offset=40  # Third page

Here's a basic implementation in Node.js with PostgreSQL:

app.get('/pets', async (req, res) => {
  const limit = parseInt(req.query.limit) || 20;
  const offset = parseInt(req.query.offset) || 0;

  // Validate inputs
  if (limit > 100) {
    return res.status(400).json({
      error: 'Limit cannot exceed 100'
    });
  }

  try {
    // Get total count
    const countResult = await db.query(
      'SELECT COUNT(*) FROM pets'
    );
    const total = parseInt(countResult.rows[0].count);

    // Get paginated results
    const result = await db.query(
      'SELECT * FROM pets ORDER BY id LIMIT $1 OFFSET $2',
      [limit, offset]
    );

    res.json({
      data: result.rows,
      pagination: {
        limit,
        offset,
        total,
        hasMore: offset + limit < total
      }
    });
  } catch (error) {
    res.status(500).json({ error: 'Database error' });
  }
});

Pros of Offset Pagination

  • Simple to implement: Just add LIMIT and OFFSET to your SQL query
  • Easy to understand: Clients can easily calculate page numbers
  • Random access: You can jump to any page directly
  • Works with any sorting: No special requirements

Cons of Offset Pagination

  • Performance degrades: As offset increases, queries get slower because the database still has to scan through all the skipped rows
  • Inconsistent results: If items are added or deleted while paginating, you might see duplicates or miss items
  • Inefficient for large datasets: OFFSET 1000000 is very slow

Here's an example of the consistency problem:

// User requests page 1
GET /pets?limit=10&offset=0
// Returns pets 1-10

// Someone adds a new pet at position 1

// User requests page 2
GET /pets?limit=10&offset=10
// Returns pets 11-20, but pet #10 is now at position 11
// User sees pet #10 twice!

Cursor-Based Pagination

Cursor-based pagination solves the consistency problem by using a pointer (cursor) to track position instead of an offset. The cursor is typically an encoded identifier that points to a specific item.

GET /api/pets?limit=20
GET /api/pets?limit=20&cursor=eyJpZCI6MTAwfQ==

Here's an implementation:

app.get('/pets', async (req, res) => {
  const limit = parseInt(req.query.limit) || 20;
  let cursor = req.query.cursor;

  // Decode cursor
  let lastId = 0;
  if (cursor) {
    try {
      const decoded = Buffer.from(cursor, 'base64').toString();
      lastId = JSON.parse(decoded).id;
    } catch (e) {
      return res.status(400).json({ error: 'Invalid cursor' });
    }
  }

  try {
    // Query items after the cursor
    const result = await db.query(
      'SELECT * FROM pets WHERE id > $1 ORDER BY id LIMIT $2',
      [lastId, limit + 1] // Fetch one extra to check if there's more
    );

    const hasMore = result.rows.length > limit;
    const items = hasMore ? result.rows.slice(0, limit) : result.rows;

    // Create next cursor
    let nextCursor = null;
    if (hasMore) {
      const lastItem = items[items.length - 1];
      nextCursor = Buffer.from(
        JSON.stringify({ id: lastItem.id })
      ).toString('base64');
    }

    res.json({
      data: items,
      pagination: {
        nextCursor,
        hasMore
      }
    });
  } catch (error) {
    res.status(500).json({ error: 'Database error' });
  }
});

Pros of Cursor Pagination

  • Consistent results: Items won't be duplicated or skipped even if data changes
  • Bettperformance: No need to skip rows; queries are fast regardless of position
  • Scales well: Works efficiently with millions of records

Cons of Cursor Pagination

  • No random access: You can't jump to page 50 directly
  • More complex: Requires encoding/decoding cursors
  • Opaque to users: Cursors are not human-readable
  • Requires stable ordering: Must have a unique, sortable field (usually ID)

Advanced Cursor Implementation

For more complex sorting (like by creation date), you need a compound cursor:

app.get('/pets', async (req, res) => {
  const limit = parseInt(req.query.limit) || 20;
  let cursor = req.query.cursor;

  let lastCreatedAt = null;
  let lastId = 0;

  if (cursor) {
    try {
      const decoded = Buffer.from(cursor, 'base64').toString();
      const parsed = JSON.parse(decoded);
      lastCreatedAt = parsed.createdAt;
      lastId = parsed.id;
    } catch (e) {
      return res.status(400).json({ error: 'Invalid cursor' });
    }
  }

  try {
    let query, params;

    if (lastCreatedAt) {
      // Use compound condition for consistent ordering
      query = `
        SELECT * FROM pets
        WHERE (created_at, id) > $2)
        ORDER BY created_at, id
        LIMIT $3
      `;
      params = [lastCreatedAt, lastId, limit + 1];
    } else {
      query = 'SELECT * FROM pets ORDER BY created_at, id 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;

    let nextCursor = null;
    if (hasMore) {
      const lastItem = items[items.length - 1];
      nextCursor = Buffer.from(JSON.stringify({
        createdAt: lastItem.created_at,
        id: lastItem.id
      })).toString('base64');
    }

    res.json({
      data: items,
      pagination: {
        nextCursor,
        hasMore,
        limit
      }
    });
  } catch (error) {
    res.status(500).json({ error: 'Database error' });
  }
});

Keyset Pagination

Keyset pagination (also called "seek method") is similar to cursor-based pagination but uses actual values instead of encoded cursors. It's more transparent and RESTful:

GET /api/pets?limit=20
GET /api/pets?limit=20&after_id=100

Implementation:

app.get('/pets', async (req, re
  const limit = parseInt(rquery.limit) || 20;
  const afterId = parseInt(req.query.after_id) || 0;

  try {
    const result = await db.query(
      'SELECT * FROM pets WHERE id > $1 ORDER BY id LIMIT $2',
      [afterId, limit + 1]
    );

    const hasMore = result.rows.length > limit;
    const items = hasMore ? result.rows.slice(0, limit) : result.rows;

    const response = {
      data: items,
      pagination: {
        limit,
        hasMore
      }
    };

    if (hasMore) {
      const lastItem = items[items.length - 1];
      response.pagination.afterId = lastItem.id;
    }

    res.json(response);
  } catch (error) {
    res.status(500).json({ error: 'Database error' });
  }
});

Pros of Keyset Pagination

  • Transparent: Parameters are human-readable
  • Performant: Same benefits as cursor pagination
  • RESTful: Uses actual resource values
  • Cacheable: URLs are predictable

Cons of Keyset Pagination

  • Exposes internal structure: Clients see your ID scheme
  • Limited to simple sorting: Complex sorts are harder to express
  • No random access: Like cursor pagination

Pagination Response Formats

How you structure your pagination metadata matters. Here are common patterns:

Pattern 1: Inline Metadata

{
  "data": [...],
  "pagination": {
    "limit": 20,
    "offset": 40,
    "total": 1000,
    "hasMore": true
  }
}
Link: <https://api.example.com/pets?page=3>; rel="next",
      <https://api.example.com/pets?page=50>; rel="last",
      <https://api.example.com/pets?page=1>; rel="first",
      <https://api.example.com/pets?page=1>; rel="prev"

Implementation:

function buildLinkHeader(baseUrl, page, limit, total) {
  const lastPage = Math.celimit);
  const links = [];

  if (page < lastPage) {
    links.push(`<${baseUrl}?page=${page + 1}&limit=${limit}>; rel="next"`);
    links.push(`<${baseUrl}?page=${lastPage}&limit=${limit}>; rel="last"`);
  }

  if (page > 1) {
    links.push(`<${baseUrl}?page=1&limit=${limit}>; rel="first"`);
    links.push(`<${baseUrl}?page=${page - 1}&limit=${limit}>; rel="prev"`);
  }

  return links.join(', ');
}

app.get('/pets', async (req, res) => {
  // ... pagination logic ...

  const linkHeader = buildLinkHeader(
    'https://api.example.com/pets',
    page,
    limit,
    total
  );

  res.set('LinkkHeader);
  res.json({ data: items });
});

Pattern 3: Cursor with Metadata

{
  "data": [...],
  "pageInfo": {
    "hasNextPage": true,
    "hasPreviousPage": false,
    "startCursor": "eyJpZCI6MX0=",
    "endCursor": "eyJpZCI6MjB9"
  }
}

Performance Optimization Tips

1. Add Indexes

Make sure your pagination fields are indexed:

CREATE INDEX idx_pets_id ON pets(id);
CREATE INDEX idx_pets_created_at_id ON pets(created_at, id);

2. Avoid COUNT(*) for Large Tables

Counting millions of rows is slow. Consider:

// Instead of exact count
const countResult = await db.query('SELECT COUNT(*) FROM pets');

// Use estimated count
const estimateResult = await db.query(`
  SELECT reltuples::bigint AS estimate
  FROM pg_class
  WHERE relname = 'pets'
`);

Or skip total count entirely:

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

3. Use Streaming for Large Responses

For very large datasets, consider streaming:

app.get('/pets/export', async (req, res) => {
  res.setHeader('Content-Type', 'application/json');
  res.write('{"data":[');

  const stream = db.query(
    new QueryStream('SELECT * FROM pets ORDER BY id')
  );

  let first = true;
  stream.on('data', (row) => {
    if (!first) res.write(',');
    res.write(JSON.stringify(row));
    first = false;
  });

  stream.on('end', () => {
    res.write(']}');
    res.end();
  });

  stream.on('error', (err) => {
    res.status(500).end();
  });
});

Which Pagination Strategy Should You Use?

Here's my recommendation based on use case:

Use offset pagination when:- You neepage numbers (like in a UI) - Dataset is small (< 10,000 items) - Random access is important - Simplicity is a priority

Use cursor pagination when:- Dataset is large (> 100,000 items) - Consistency is critical - You're building infinite scroll - Performance matters more than simplicity

Use keyset pagination when:- You want cursor benefits with transparency - Sorting is simple (single field or ID) - You want RESTful, cacheable URLs

For most modern APIs, I recommend cursor-based pagination. It scales better, provides consistent results, and works well with real-time data.

Implementing Bidirectional Pagination

Sometimes you need to paginate both forward and backward:

app.get('/pets', async (req, res) => {
  const limit = parseInt(req.query.limit) || 20;
  const afterCursor = req.query.after;
  const beforeCursor = req.query.before;

  let query, params;

  if (afterCursor) {
    const { id } = decodeCursor(afterCursor);
    query = 'SELECT * FROM pets WHERE id > $1 ORDER BY id LIMIT $2';
    params = [id, limit + 1];
  } else if (beforeCursor) {
    const { id } = decodeCursor(beforeCursor);
    query = `
      SELECT * FROM (
        SELECT * FROM pets WHERE id < $1 ORDER BY iLIMIT $2
      ) sub ORDER BY id
    `;
    params = [id, limit + 1];
  } else {
    query = 'SELECT * FROM pets ORDER BY id 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;

  res.json({
    data: items,
    pageInfo: {
      hasNextPage: afterCursor ? hasMore : false,
      hasPreviousPage: beforeCursor ? hasMore : false,
      startCursor: encodeCursor(items[0]),
      endCursor: encodeCursor(items[items.length - 1])
    }
  });
});

Wrapping Up

Pagination is one of thosehings that seems simple until you actuaplement it. The right strategy depends on your specific needs:

  • Offset pagination is great for small datasets and when you need page numbers
  • Cursor pagination is best for large datasets and infinite scroll
  • Keyset pagination offers a middle ground with transparency

Whatever you choose, make sure to: - Add proper indexes - Validate input parameters - Document your pagination clearly - Consider performance at scale - Test with realistic data volumes

Start with the simplest approach that meets your needs, and don't be afraid to switch strategies as your requirements evolve. Your users will appreciate fast, c pagination no matter which approach you choose.