Skip to main content

API Pagination Patterns: Cursor vs Offset vs Keyset Guide

Pagination is fundamental to every API that returns lists. Choosing the wrong strategy causes performance cliffs, duplicate results, and poor developer experience. This guide covers the three main patterns with real code and trade-off analysis.

โœ“ Offset, cursor & keyset compared โœ“ Link header RFC 5988 standard โœ“ Relay connection spec โœ“ SQL query examples

Offset Pagination

The classic approach โ€” simple to build and understand but degrades on large datasets because the database must scan all skipped rows:

// GET /posts?page=3&limit=20
// Response includes metadata for UI controls

app.get('/posts', async (req, res) => {
  const page = Math.max(1, parseInt(req.query.page) || 1);
  const limit = Math.min(100, parseInt(req.query.limit) || 20);
  const offset = (page - 1) * limit;

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

  const totalPages = Math.ceil(total.rows[0].count / limit);

  res.json({
    data: items.rows,
    meta: {
      page,
      limit,
      total: parseInt(total.rows[0].count),
      totalPages,
      hasNext: page < totalPages,
      hasPrev: page > 1,
    },
    links: {
      first: `/posts?page=1&limit=${limit}`,
      prev: page > 1 ? `/posts?page=${page - 1}&limit=${limit}` : null,
      next: page < totalPages ? `/posts?page=${page + 1}&limit=${limit}` : null,
      last: `/posts?page=${totalPages}&limit=${limit}`,
    },
  });
});

Cursor-Based Pagination (Opaque Cursor)

Cursor pagination encodes position as an opaque string. The server decodes it; clients treat it as a black box. No missing or duplicate items when data changes between pages:

// GET /posts?after=eyJpZCI6NDJ9&limit=20
// Cursor = base64(JSON.stringify({ id: 42 }))

function encodeCursor(id: number): string {
  return Buffer.from(JSON.stringify({ id })).toString('base64url');
}

function decodeCursor(cursor: string): { id: number } {
  return JSON.parse(Buffer.from(cursor, 'base64url').toString());
}

app.get('/posts', async (req, res) => {
  const limit = Math.min(100, parseInt(req.query.limit) || 20);
  const after = req.query.after as string | undefined;

  let query: string;
  let params: any[];

  if (after) {
    const { id } = decodeCursor(after);
    query = 'SELECT * FROM posts WHERE id < $1 ORDER BY id DESC LIMIT $2';
    params = [id, limit + 1];  // fetch one extra to determine hasNextPage
  } else {
    query = 'SELECT * FROM posts ORDER BY id DESC LIMIT $1';
    params = [limit + 1];
  }

  const rows = await db.query(query, params);
  const hasNextPage = rows.length > limit;
  const items = hasNextPage ? rows.slice(0, limit) : rows;

  res.json({
    data: items,
    pageInfo: {
      hasNextPage,
      endCursor: items.length ? encodeCursor(items[items.length - 1].id) : null,
    },
  });
});

Keyset Pagination (Seek Method)

Keyset pagination uses compound WHERE clauses matching the ORDER BY columns. It hits the index directly โ€” O(1) for any page depth:

-- Keyset query โ€” no OFFSET, uses index on (created_at, id)
-- First page
SELECT * FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Next page โ€” cursor encodes (created_at, id) of last item
SELECT * FROM posts
WHERE (created_at, id) < ('2026-01-10T12:00:00Z', 99)
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Ensure the index exists for O(log n) performance
CREATE INDEX idx_posts_keyset ON posts (created_at DESC, id DESC);

-- Node.js implementation
const cursor = { createdAt: lastItem.created_at, id: lastItem.id };
const encoded = Buffer.from(JSON.stringify(cursor)).toString('base64url');

// Next request: GET /posts?cursor=eyJjcmVhdGVkQXQ...
// Server decodes and builds the WHERE clause above

Offset vs Cursor vs Keyset Comparison

Criteria Offset Cursor (opaque) Keyset (seek)
Implementation Very simple Moderate Moderate
Deep page perf O(n) โ€” degrades O(log n) O(log n)
Skip to page N Yes No No
Stable on mutations Can miss/repeat items Stable Stable
Total count Easy (COUNT query) Extra query needed Extra query needed
Best for Admin UIs, small data Feeds, APIs High-scale read APIs
Share this tool: X / Twitter LinkedIn
โœ“ 60,000+ developers use DevPlaybook โ˜…โ˜…โ˜…โ˜…โ˜… 4.8 / 5 on Gumroad โœ“ MIT licensed ยท Instant download โœ“ No login required

Related Products

on Gumroad

You Might Also Need

More Free Tools

OpenAPI Validator API Response Mock Generator API Response Time Tester OpenAPI Schema Validator OpenAPI Mock Generator API Test Case Generator Web Performance Checklist API Request Builder HTTP API Tester SQL ERD Diagram Generator API Mock Server Config Generator Neon DB Query Builder Turso Edge DB Explorer API Rate Limit Calculator API Response Formatter & Analyzer LLM Cost Calculator SQL Index Advisor Prisma Schema Visualizer AI Prompt Cost Calculator Database Index Analyzer REST API Request Builder Database Schema Visualizer Lighthouse Score Simulator LLM Prompt Cost Calculator Database Index Advisor API Response Mocker AI Rate Limit Calculator Web Vitals Budget Planner Database Schema Diff PlanetScale Branch Manager Core Web Vitals Guide API Key Generator AI Doc Generator โ€” Pro Tool HTTP Methods Reference HTTP Code Snippet Generator HTTP Status Code Reference HTTP Status Code Reference Interview Question Generator JSON Diff Viewer JSON Diff UUID v7 Generator Rate Limiter Tester API Changelog Generator HTTP Mock Response Builder Web Vitals Scorer Rate Limit Calculator CORS Policy Builder Haproxy Config Validator HAR Diff Analyzer HTTP Status Code Lookup MySQL Query Explainer PostgreSQL EXPLAIN Analyzer HTTP Status Code Lookup Vue Component Generator HTTP Status Code Reference LLM Context Window Calculator LLM Token Counter k6 Load Test Script Generator HTTP/2 Server Push Headers Builder Web Vitals Analyzer Prompt Token Estimator Mock Service Worker (MSW) API Mocking Guide Pulumi Modern IaC Guide Core Web Vitals Calculator SQL to TypeScript Types AI Agent Memory Planner RAG Chunking Calculator HTML5 Canvas API Playground LLM Benchmark Calculator Token Context Calculator k6 Script Generator RAG Chunking Calculator OpenAI Function Calling Generator Core Web Vitals Budget Calculator Bundle Size Budget Calculator Image Optimization Calculator TTFB Analyzer Token Counter & Cost Estimator Embedding Storage Calculator RAG Chunk Size Optimizer AI API Rate Limit Calculator PostgreSQL Connection String Builder Database Query Cost Analyzer

Related Articles

Apr 1, 2026 ยท 14 min read

PostgreSQL Performance Tuning: Indexing Strategies Every Developer Should Know

Master PostgreSQL indexing in 2026: EXPLAIN ANALYZE, B-tree vs GIN vs GiST indexes, partial indexes, covering indexes, and real-world optimization patterns. With code examples and query plans.

Apr 1, 2026 ยท 13 min read

Redis Caching Patterns for Node.js: Cache-Aside, Write-Through, and TTL Strategies

Master Redis caching patterns in Node.js: cache-aside, write-through, write-behind, read-through, and TTL strategies. Code examples, when to use each pattern, and cache invalidation techniques.

Mar 27, 2026 ยท 15 min read

API Rate Limiting: Algorithms and Implementation Guide 2026

Complete guide to API rate limiting in 2026. Token Bucket, Leaky Bucket, Sliding Window algorithms with Node.js and Redis implementations, distributed rate limiting, 429 handling, and best practices.

Mar 27, 2026 ยท 16 min read

Axum vs Actix-web vs Poem: Best Rust Web Frameworks in 2026

A comprehensive comparison of Axum, Actix-web, and Poem โ€” the top three Rust web frameworks in 2026. Covers performance benchmarks, ergonomics, middleware, ecosystem, and code examples to help you choose the right framework.

Mar 27, 2026 ยท 14 min read

Hono vs Express: The New Node.js Server Framework Battle in 2026

A comprehensive comparison of Hono and Express in 2026 โ€” performance benchmarks, middleware, TypeScript support, edge deployment, ecosystem maturity, and which framework to choose for your next API.

Enjoying this tool? Get more like it.

Weekly developer tool guides, productivity playbooks & AI tricks. Free. No spam.

DevPlaybook Pro 7 days free

Unlock AI-Powered Dev Tools

  • โšก AI Code Review, Doc Generator & SQL Builder
  • โšก All premium templates & early access
  • โšก Member discounts on Gumroad products
Start Free Trial โ†’

$9/mo after trial ยท Cancel anytime

Want the full toolkit?

Get DevPlaybook Pro

Every template, guide, boilerplate, and automation script in one bundle. 13 premium products โ€” grab them all at once and save big.

$79
$241 separately Save $162 (67% off)
Get DevPlaybook Pro โ€” $79 โ†’

MIT licensed ยท Instant download ยท No subscription

See what's included โ†’ Browse all deals โ†’

Frequently Asked Questions

What is the simplest pagination strategy to implement?▼

Offset pagination (page and limit query parameters) is the simplest to implement and understand. You pass page=2&limit=20 and the server skips (page-1)*limit records. It works well with SQL LIMIT and OFFSET, is easy to build UI for (page numbers, jump to page), and is familiar to most developers. The main drawbacks are performance on large datasets and the possibility of duplicate or missed items when records are inserted or deleted during pagination.

When should I use cursor-based pagination?▼

Use cursor-based pagination when your dataset is large (millions of rows), when items are frequently inserted or deleted, or when you need real-time feeds (like social media timelines). A cursor encodes the position of the last fetched item as an opaque string (usually base64-encoded id or timestamp). The server uses it to fetch the next page efficiently without counting skipped rows, making it O(1) regardless of page depth.

What is keyset pagination?▼

Keyset pagination is a database-efficient variant of cursor pagination where the cursor is a set of column values that uniquely identifies a position. Instead of OFFSET, the query uses WHERE (created_at, id) < (cursor_time, cursor_id) ORDER BY created_at DESC, id DESC. This uses the database index efficiently and performs consistently even on page 10,000. It is sometimes called seek-method pagination.

What are Link headers in REST pagination?▼

RFC 5988 defines Link headers as a standard way to communicate pagination links in HTTP responses without embedding them in the response body. GitHub's API popularized this pattern: Link: <https://api.example.com/items?page=3>; rel='next', <https://api.example.com/items?page=1>; rel='first'. This keeps the response body clean (just the data array) and allows clients to follow links generically.

What is the Relay connection spec?▼

The Relay connection specification is a GraphQL pagination convention popularized by Facebook's Relay client. It defines a standardized shape: edges array (each with a node and cursor), pageInfo object (hasNextPage, hasPreviousPage, startCursor, endCursor), and the use of first/after and last/before arguments. Many GraphQL APIs adopt this spec even without using Relay to give clients predictable cursor pagination.

What is the maximum page size I should allow?▼

A common best practice is to cap page size at 100-200 items and default to 20-50. Allowing unlimited page sizes exposes your API to denial-of-service via requests that force the database to scan millions of rows or serialize enormous JSON responses. Always validate and enforce a maximum limit server-side, even if the client passes a higher value.