ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeCursor-Based Pagination in Node.js: Why Offset Pagination Breaks at Scale

Cursor-Based Pagination in Node.js: Why Offset Pagination Breaks at Scale

Why offset pagination breaks at scale — and a drop-in cursor implementation that stays fast at 10 million rows

ZyVOP
ZyVOP
Senior Developer
May 26, 2026
6 min read
Cursor-Based Pagination in Node.js: Why Offset Pagination Breaks at Scale
#offset vs cursor pagination#cursor-based pagination PostgreSQL#cursor pagination Node.js#pagination API 2026#keyset pagination Node.js#infinite scroll API Node.js#compound cursor pagination

Most developers reach for offset pagination first. It is intuitive — page=3&limit=20 maps directly to OFFSET 60 LIMIT 20 in SQL. It works fine in development. It starts showing problems at 100,000 rows and is genuinely broken at 10 million.

This guide explains exactly why offset pagination fails, how cursor-based pagination fixes it, and gives you a full implementation you can drop into any Node.js API.


Why Offset Pagination Breaks

The SQL for offset pagination looks innocent:

SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 10000;

What Postgres actually does: scans and discards the first 10,000 rows, then returns the next 20. The work grows linearly with the offset value. Page 1 is fast. Page 500 is slow. Page 5,000 times out.

Run this and watch the difference:

-- Page 1: fast regardless of table size
EXPLAIN ANALYZE
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0;
-- actual time: ~0.5ms

-- Page 500: painful on large tables
EXPLAIN ANALYZE
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
-- actual time: ~180ms on 1M rows, worse as table grows

The second problem is consistency. If a new order is inserted while a user pages through results, every subsequent page is shifted by one row — users see duplicates or skip records entirely. On a busy table, this is not an edge case.


How Cursor Pagination Works

Instead of saying "skip N rows," cursor pagination says "give me rows that come after this specific record." The cursor encodes the position of the last item the client saw — typically its ID and sort field value.

-- Instead of OFFSET, use a WHERE clause with the last seen values
SELECT * FROM orders
WHERE (created_at, id) < ($last_created_at, $last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;

This query uses an index regardless of how far into the dataset you are. Page 1 and page 5,000 have identical performance. And because you are filtering by a fixed point in the data, new inserts do not shift your position.

The tradeoffs: you cannot jump to an arbitrary page number, and the sort order must be deterministic (which is why we include id as a tiebreaker — created_at alone can have duplicates).


Full Implementation

The Cursor

The cursor is a base64-encoded JSON object containing the values needed to resume from a specific position. Never expose raw database IDs or timestamps directly — encoding lets you change the internal structure without breaking clients.

// src/lib/pagination.ts

interface CursorData {
  id:        string;
  sortValue: string;   // ISO string for dates, string for other sort fields
}

export function encodeCursor(data: CursorData): string {
  return Buffer.from(JSON.stringify(data)).toString('base64url');
}

export function decodeCursor(cursor: string): CursorData | null {
  try {
    const decoded = Buffer.from(cursor, 'base64url').toString('utf-8');
    return JSON.parse(decoded);
  } catch {
    return null;
  }
}

export interface PaginationResult<T> {
  data:       T[];
  pagination: {
    hasNextPage:  boolean;
    hasPrevPage:  boolean;
    nextCursor:   string | null;
    prevCursor:   string | null;
    totalCount?:  number;     // Optional — expensive to compute, include only when needed
  };
}

Generic Cursor Pagination Helper

// src/lib/pagination.ts (continued)
import { Pool } from 'pg';

interface CursorPaginateOptions {
  db:          Pool;
  table:       string;
  sortField:   string;          // e.g. 'created_at'
  sortOrder:   'ASC' | 'DESC';
  limit:       number;
  cursor?:     string;
  where?:      string;          // Additional WHERE conditions
  params?:     any[];           // Params for the where clause
  select?:     string;          // Columns to select (default: *)
}

export async function cursorPaginate<T>({
  db,
  table,
  sortField,
  sortOrder,
  limit,
  cursor,
  where    = '',
  params   = [],
  select   = '*',
}: CursorPaginateOptions): Promise<PaginationResult<T>> {

  // Fetch one extra to know if there is a next page
  const fetchLimit = limit + 1;
  const operator   = sortOrder === 'DESC' ? '<' : '>';
  const conditions: string[] = [];
  const queryParams: any[]   = [...params];

  if (where) conditions.push(where);

  if (cursor) {
    const decoded = decodeCursor(cursor);
    if (!decoded) throw new Error('Invalid cursor');

    // Compound comparison: (sortField, id) < (value, id)
    // This handles ties in sortField correctly
    queryParams.push(decoded.sortValue, decoded.id);
    const p1 = queryParams.length - 1;
    const p2 = queryParams.length;

    conditions.push(
      `(${sortField}, id) ${operator} ($${p1}, $${p2})`
    );
  }

  const whereClause = conditions.length > 0
    ? `WHERE ${conditions.join(' AND ')}`
    : '';

  queryParams.push(fetchLimit);
  const limitParam = queryParams.length;

  const query = `
    SELECT ${select}
    FROM ${table}
    ${whereClause}
    ORDER BY ${sortField} ${sortOrder}, id ${sortOrder}
    LIMIT $${limitParam}
  `;

  const result = await db.query<T>(query, queryParams);
  const rows   = result.rows;

  const hasNextPage = rows.length > limit;
  if (hasNextPage) rows.pop();  // Remove the extra row

  const nextCursor = hasNextPage && rows.length > 0
    ? encodeCursor({
        id:        (rows[rows.length - 1] as any).id,
        sortValue: String((rows[rows.length - 1] as any)[sortField]),
      })
    : null;

  const prevCursor = cursor && rows.length > 0
    ? encodeCursor({
        id:        (rows[0] as any).id,
        sortValue: String((rows[0] as any)[sortField]),
      })
    : null;

  return {
    data: rows,
    pagination: {
      hasNextPage,
      hasPrevPage: !!cursor,
      nextCursor,
      prevCursor,
    },
  };
}

Route Implementation

// src/routes/orders.ts
import { z } from 'zod';
import { cursorPaginate } from '../lib/pagination';

const ListOrdersSchema = z.object({
  limit:  z.coerce.number().int().min(1).max(100).default(20),
  cursor: z.string().optional(),
  status: z.enum(['pending', 'paid', 'shipped', 'cancelled']).optional(),
  sort:   z.enum(['newest', 'oldest']).default('newest'),
});

router.get('/orders', authenticate, async (req, res) => {
  const query = ListOrdersSchema.safeParse(req.query);
  if (!query.success) {
    return res.status(400).json({ error: 'Invalid query parameters' });
  }

  const { limit, cursor, status, sort } = query.data;
  const sortOrder = sort === 'newest' ? 'DESC' : 'ASC';

  // Build tenant-scoped where clause
  const whereConditions = ['tenant_id = $1'];
  const params: any[]   = [req.tenant.id];

  if (status) {
    params.push(status);
    whereConditions.push(`status = $${params.length}`);
  }

  const result = await cursorPaginate({
    db:        pool,
    table:     'orders',
    sortField: 'created_at',
    sortOrder,
    limit,
    cursor,
    where:     whereConditions.join(' AND '),
    params,
    select:    'id, status, total, created_at, user_id',
  });

  res.json(result);
});

Response shape:

{
  "data": [
    { "id": "uuid-1", "status": "paid", "total": "149.99", "created_at": "..." },
    { "id": "uuid-2", "status": "pending", "total": "89.00", "created_at": "..." }
  ],
  "pagination": {
    "hasNextPage": true,
    "hasPrevPage": false,
    "nextCursor": "eyJpZCI6InV1aWQtMiIsInNvcnRWYWx1ZSI6Ii4uLiJ9",
    "prevCursor": null
  }
}

How the Client Uses It

// Client-side — fetch next page
async function fetchNextPage(currentCursor: string | null) {
  const params = new URLSearchParams({ limit: '20' });
  if (currentCursor) params.set('cursor', currentCursor);

  const res  = await fetch(`/api/orders?${params}`);
  const data = await res.json();

  return {
    orders:     data.data,
    nextCursor: data.pagination.nextCursor,
    hasMore:    data.pagination.hasNextPage,
  };
}

// Infinite scroll implementation
let cursor: string | null = null;
let hasMore = true;

async function loadMore() {
  if (!hasMore) return;
  const { orders, nextCursor, hasMore: more } = await fetchNextPage(cursor);
  appendToList(orders);
  cursor  = nextCursor;
  hasMore = more;
}

When You Still Need Total Count

Cursor pagination deliberately avoids COUNT(*) — it is expensive on large tables. But some UIs genuinely need "showing 1–20 of 4,832 results."

If you need it, run the count query separately and cache it:

// Cache the count for 60 seconds — it doesn't need to be exact
async function getOrderCount(tenantId: string, status?: string): Promise<number> {
  const cacheKey = `count:orders:${tenantId}:${status || 'all'}`;
  const cached   = await redis.get(cacheKey);
  if (cached) return parseInt(cached);

  const whereClause = status
    ? 'WHERE tenant_id = $1 AND status = $2'
    : 'WHERE tenant_id = $1';
  const params = status ? [tenantId, status] : [tenantId];

  const result = await db.query(
    `SELECT COUNT(*) FROM orders ${whereClause}`,
    params
  );

  const count = parseInt(result.rows[0].count);
  await redis.setex(cacheKey, 60, count.toString());
  return count;
}

Only fetch the count on the first page request — subsequent page fetches do not need it.


The Index That Makes This Fast

The cursor query only performs well if Postgres can use an index for the compound sort. Create a covering index that matches your sort:

-- For ORDER BY created_at DESC, id DESC
CREATE INDEX CONCURRENTLY idx_orders_cursor_desc
  ON orders(created_at DESC, id DESC)
  WHERE status != 'cancelled';   -- Partial index if you filter by status often

-- For multi-tenant setups, include tenant_id first
CREATE INDEX CONCURRENTLY idx_orders_tenant_cursor
  ON orders(tenant_id, created_at DESC, id DESC);

Verify the index is being used:

EXPLAIN ANALYZE
SELECT * FROM orders
WHERE tenant_id = 'uuid'
  AND (created_at, id) < ('2026-01-01', 'uuid-last')
ORDER BY created_at DESC, id DESC
LIMIT 21;
-- Should show: Index Scan using idx_orders_tenant_cursor
-- Should NOT show: Seq Scan

Offset vs Cursor: When to Use Each

Use offset when:                    Use cursor when:
────────────────────────────────    ─────────────────────────────────
Table has < 50,000 rows             Table has or will have > 100k rows
Users need to jump to page N        Infinite scroll / load more UI
Admin reporting (exact page nav)    Real-time data (new rows inserted often)
Simple implementation needed        Performance at scale is required

If you are building a user-facing feed, activity stream, notification list, or any list that grows continuously — cursor pagination is the right choice from day one. Retrofitting it later means updating both your API and your clients simultaneously.

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1