Offset vs Cursor Pagination: 2025 Best Practices [TypeScript]

Offset pagination is like counting seats in a moving bus. Cursor (keyset) pagination watches the door. In 2025, the winning cursor is a composite cursor—typically {createdAt, id}—that supports forward and reverse infinite scroll, offline replay, and dedupe like a pro.

Table of Contents

  1. Why offset still bites (and when it’s fine)
  2. Cursor 101—in one minute
  3. The secret sauce: composite cursors (time + id)
  4. Reverse infinite scrolling without jitter
  5. Offline replay on mobile (aka “Catch me up”)
  6. Edge cases & production checklist
  7. Drop-in TypeScript utilities (client + server)
  8. Metrics, monitoring, and accessibility
  9. FAQ

1) Why offset still bites (and when it’s fine)

  • Drift under writes: New rows push old ones down; OFFSET x LIMIT y shows gaps/dupes as the list changes.
  • Hot tables = slow: Big offsets force the database to skip lots of rows.
  • When it’s fine: Small, mostly static lists (e.g., admin tables) or where exact consistency doesn’t matter.

If your feed is an ever-moving timeline (orders, messages, activities), offset is a drama magnet. Cursor it is.


2) Cursor 101—in one minute

Cursor (a.k.a. keyset) pagination asks the database: “Give me the next N items after this item.”
Typical pattern:

-- "Next" page (newer → older)
SELECT * FROM items
WHERE (created_at, id) < ($cursor_ts, $cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;

Tie-break on id to keep ordering stable when timestamps collide. Return a token (the “cursor”) so the client knows where to resume.


3) The secret sauce: composite cursors (time + id)

Single keys are cute until reality happens. Composite cursors:

  • Stable ordering: ORDER BY created_at DESC, id DESC.
  • No skips/dupes: (created_at, id) comparison is total and deterministic.
  • Works across sharded/id-not-monotonic systems (the time dimension does the heavy lift).

Indexing

-- Postgres / MySQL (InnoDB) composite index
CREATE INDEX idx_items_created_id_desc ON items (created_at DESC, id DESC);

Tip: Use a server-stamped created_at (not client time) to avoid timezone chaos. If your primary key is ULID/Snowflake (time-ordered), you can use {ulid, tieBreak=sequence} similarly.


4) Reverse infinite scrolling without jitter

Users scroll down for older items and up to read history. Support both:

  • Forward (newer → older): WHERE (created_at, id) < (cursor_ts, cursor_id) ORDER BY created_at DESC, id DESC
  • Reverse (older → newer): WHERE (created_at, id) > (cursor_ts, cursor_id) ORDER BY created_at ASC, id ASC
    Return items ASC from the DB, reverse them on the client if your UI is newest-first.

Keep scroll position when prepending older content. Many libraries (e.g., @tanstack/react-virtual) maintain anchor offsets so the page doesn’t “jump like a caffeinated kangaroo.”


5) Offline replay on mobile (aka “Catch me up”)

When the app comes back online, fetch new items since the last seen cursor:

  • Store lastSeenCursor (topmost item) in IndexedDB/LocalStorage.
  • On resume, call /items?since=lastSeenCursor and prepend results.
  • Keep a dedupe set (by id) to avoid double-render.

This gives users a magical “you’re up to date” moment—no awkward reloads.


6) Edge cases & production checklist

Consistency & ties

  • Always tie-break on a unique id.
  • If you allow edits changing created_at, treat edits as new rows in an append-only event log (safer for timelines).

Deletes

  • Deleting rows won’t break cursor math, but the page could look sparse. Consider a “This item was removed” stub if context matters.

Backfill & imports

  • For bulk backfills, insert with historical created_at; your composite cursor still holds.

Security

  • Sign your cursor (HMAC). Don’t expose raw internals if you worry about users poking at timestamps.

A11y

  • Provide a “Load previous” button (keyboard/focusable) in addition to scroll triggers; announce updates with aria-live="polite".

Monitoring

  • Track % duplicate ids merged, % empty pages, and median/95p page time.

7) Drop-in TypeScript utilities (client + server)

7.1 Cursor utils (encode/decode + HMAC)

// cursor.ts (Node/Browser with conditional crypto)
type Cursor = { ts: string; id: string; dir: 'next' | 'prev' };

export function encodeCursor(c: Cursor, secret?: string): string {
  const payload = `${c.ts}|${c.id}|${c.dir}`;
  const sig = secret ? hmacSHA256(payload, secret) : '';
  const raw = JSON.stringify({ ...c, sig });
  return base64url(raw);
}

export function decodeCursor(token?: string, secret?: string): Cursor | null {
  if (!token) return null;
  const raw = JSON.parse(fromBase64url(token));
  const { ts, id, dir, sig } = raw;
  if (secret) {
    const expect = hmacSHA256(`${ts}|${id}|${dir}`, secret);
    if (sig !== expect) throw new Error('Bad cursor signature');
  }
  return { ts, id, dir };
}

// minimal helpers (Node)
import crypto from 'crypto';
function hmacSHA256(s: string, key: string) {
  return crypto.createHmac('sha256', key).update(s).digest('base64url');
}
function base64url(s: string) {
  return Buffer.from(s).toString('base64url');
}
function fromBase64url(b: string) {
  return Buffer.from(b, 'base64url').toString();
}

7.2 Express + Postgres example (both directions)

// items.route.ts
import express from 'express';
import { decodeCursor, encodeCursor } from './cursor';
const router = express.Router();

router.get('/items', async (req, res) => {
  const dirParam = (req.query.dir as string) || 'next'; // 'next' | 'prev'
  const dir = dirParam === 'prev' ? 'prev' : 'next';
  const token = (req.query.cursor as string) || '';
  const limit = Math.max(1, Math.min(parseInt(req.query.limit as string) || 50, 100));
  const cursor = decodeCursor(token, process.env.CURSOR_SECRET);

  // Build SQL safely with parameterized queries
  let sql = '';
  let params: any[] = [limit];

  if (!cursor) {
    sql =
      dir === 'next'
        ? `SELECT * FROM items ORDER BY created_at DESC, id DESC LIMIT $1`
        : `SELECT * FROM items ORDER BY created_at ASC,  id ASC  LIMIT $1`;
  } else {
    params = [cursor.ts, cursor.id, limit];
    sql =
      dir === 'next'
        ? `SELECT * FROM items
           WHERE (created_at, id) < ($1::timestamptz, $2::text)
           ORDER BY created_at DESC, id DESC
           LIMIT $3`
        : `SELECT * FROM items
           WHERE (created_at, id) > ($1::timestamptz, $2::text)
           ORDER BY created_at ASC, id ASC
           LIMIT $3`;
  }

  const result = await req.db.query(sql, params);
  let rows = result.rows as { id: string; created_at: string }[];

  // If we fetched 'prev' in ASC, but UI shows newest-first, reverse them
  if (dir === 'prev') rows = rows.reverse();

  const nextCursor =
    rows.length > 0
      ? encodeCursor({ ts: rows[rows.length - 1].created_at, id: rows[rows.length - 1].id, dir: 'next' }, process.env.CURSOR_SECRET)
      : null;

  const prevCursor =
    rows.length > 0
      ? encodeCursor({ ts: rows[0].created_at, id: rows[0].id, dir: 'prev' }, process.env.CURSOR_SECRET)
      : null;

  res.json({ items: rows, nextCursor, prevCursor });
});

export default router;

7.3 React (Next.js) infinite + reverse, with dedupe & scroll-safe prepend

// useItems.tsx
import { useInfiniteQuery } from '@tanstack/react-query';

type Page = { items: Item[]; nextCursor?: string; prevCursor?: string };
async function fetchPage(cursor?: string, dir: 'next'|'prev'='next'): Promise<Page> {
  const q = new URLSearchParams();
  if (cursor) q.set('cursor', cursor);
  q.set('dir', dir);
  const res = await fetch(`/api/items?${q.toString()}`);
  return res.json();
}

export function useTimeline() {
  return useInfiniteQuery({
    queryKey: ['items'],
    queryFn: ({ pageParam }) => fetchPage(pageParam?.cursor, pageParam?.dir ?? 'next'),
    initialPageParam: { cursor: undefined as string|undefined, dir: 'next' as const },
    getNextPageParam: (last) => last.nextCursor ? { cursor: last.nextCursor, dir: 'next' } : undefined,
    getPreviousPageParam: (first) => first.prevCursor ? { cursor: first.prevCursor, dir: 'prev' } : undefined,
    select: (data) => {
      // Flatten + dedupe by id
      const seen = new Set<string>();
      const items = data.pages.flatMap(p => p.items).filter(x => !seen.has(x.id) && seen.add(x.id));
      return { items, hasMoreNext: !!data.pages.at(-1)?.nextCursor, hasMorePrev: !!data.pages[0]?.prevCursor };
    }
  });
}
TSX

7.4 Offline replay: “Since last seen”

// offline.ts
import { get, set } from 'idb-keyval';

const KEY = 'lastSeenCursor';

export async function saveTopCursor(cursor: string) { await set(KEY, cursor); }
export async function loadTopCursor(): Promise<string | undefined> { return (await get(KEY)) ?? undefined; }

// On app resume:
export async function catchMeUp() {
  const since = await loadTopCursor();
  if (!since) return;
  const res = await fetch(`/api/items?since=${encodeURIComponent(since)}`);
  const { items, nextCursor } = await res.json();
  // prepend items, re-render…
  if (nextCursor) await saveTopCursor(nextCursor);
}
TypeScript

8) Metrics, monitoring, and accessibility

  • Metrics: page_db_ms, items_per_page, %dup_merged, %empty_pages, scroll_jitter_events.
  • Logs: cursor_in, cursor_out, dir, count, first_id, last_id.
  • A11y: Always include a visible “Load previous” button (keyboard focusable) and announce “Loaded 20 older items” via an aria-live region. Infinite scroll without a button is a trap for keyboard users.

9) FAQ

Q: Can I just use id as the cursor?
A: If id is strictly time-ordered (e.g., ULID/Snowflake) and globally unique, maybe. But a composite {createdAt, id} guards against backfills, clock quirks, and ties—cheap insurance.

Q: How big should a page be?
A: Start with 50 for list UIs, 100 for machine endpoints. Track median/95p UI frame drops and adjust.

Q: What about deletions and edits?
A: Cursor math survives deletions. For edits that change order, prefer append-only events; feeds are friendlier when items don’t teleport.

Leave a Reply

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