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
- Why offset still bites (and when it’s fine)
- Cursor 101—in one minute
- The secret sauce: composite cursors (time + id)
- Reverse infinite scrolling without jitter
- Offline replay on mobile (aka “Catch me up”)
- Edge cases & production checklist
- Drop-in TypeScript utilities (client + server)
- Metrics, monitoring, and accessibility
- FAQ
1) Why offset still bites (and when it’s fine)
- Drift under writes: New rows push old ones down;
OFFSET x LIMIT yshows 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=lastSeenCursorand 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 };
}
});
}
TSX7.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);
}TypeScript8) 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-liveregion. 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.