SQL's OFFSET clause forces PostgreSQL to scan and discard the first N rows before returning results, making the cost grow linearly with page depth — O(OFFSET). On a 1-million-row table, a query that takes 10ms at page 1 can take 5,000ms at page 1,000. Even with a B-tree index on the sort column, the database must traverse the index from the beginning to reach the offset position.
Cursor pagination replaces OFFSET with a WHERE clause that filters by the last seen value, turning a linear scan into an O(log N) index range query. For example, instead of skipping 10,000 rows, the query asks for rows where created_at is less than the last cursor value. This approach benchmarks at 17x faster than offset pagination for deep pages on large datasets.
If you sort by a non-unique column like created_at, two records created at the exact same millisecond would produce an ambiguous cursor, causing pages to overlap or skip records. Composing the cursor from both (created_at, id) and filtering with WHERE (created_at, id) < ($cursorDate, $cursorId) guarantees stable, unambiguous pagination. PostgreSQL supports row-value comparisons on composite indexes, keeping this efficient.
Cursor pagination does not support jumping to an arbitrary page — you must paginate sequentially. For most UIs like infinite scroll or next/previous buttons this is fine, but admin interfaces that let users type a page number require a different approach. The post recommends a hybrid: use cursor pagination for sequential next/previous navigation and offset only for the page-number jump input.
Cursor values should be opaque to the client, encoded as base64url JSON containing the raw sort key values (e.g., created_at and id of the last item). Using base64url avoids padding characters that can cause issues in query strings. On the server, always decode and validate cursor values before using them in SQL to prevent injection attacks, and handle the case where the cursor points to a deleted record by returning an empty page gracefully.
OFFSET 10000 forces PostgreSQL to scan and discard 10,000 rows before returning results. At page 1 (OFFSET 0), your query takes 10ms. At page 1,000 (OFFSET 10,000), the same query on a 1-million-row table can take 5,000ms. Benchmarks show cursor pagination is 17x faster than offset for deep pages on large datasets. I hit this wall when building an ERP transaction history endpoint — page 500 of an invoice list was timing out. Switching to cursor pagination fixed it in an afternoon.
| Dimension | Offset Pagination | Cursor Pagination |
|---|---|---|
| Query performance at depth | Degrades linearly, page 1,000 on 1M rows can take seconds | Constant time regardless of page depth |
| Implementation complexity | Trivial: LIMIT and OFFSET | Needs a stable sort key and opaque cursor encoding |
| Random page access | Supports jumping to any page number | Only supports next or previous, no arbitrary page jump |
| Consistency under writes | Rows can shift between pages if data changes mid-scroll | Stable results even as rows are inserted or deleted |
| Total count display | Easy: COUNT alongside the query | Requires a separate count query, often skipped for performance |
| Best fit | Admin tables with page numbers and small datasets | Infinite scroll and large datasets like transaction history |
SQL's OFFSET clause tells the database to skip N rows before returning results. To do this, PostgreSQL must identify the first N rows (which requires scanning or indexing), throw them away, and then return the next M rows. As OFFSET grows, so does the work. This is O(OFFSET) — the cost grows linearly with how deep into the dataset you go. For a table with a B-tree index on the sort column, the index scan is fast for small offsets. For large offsets, the database still must traverse the index from the beginning to reach the offset position.
Offset pagination has a second problem: data consistency. If you're on page 5 of an invoice list (OFFSET 40, LIMIT 10) and a new invoice is inserted at the top of the sort order, when you request page 6, you'll either see a duplicate of the last invoice from page 5 or skip an invoice entirely. This is because OFFSET is a positional operation — it doesn't know about records, only row positions. In high-write systems like an ERP, this means users paginating through transaction history can get inconsistent views.
Cursor pagination replaces OFFSET with a WHERE clause that filters by the last seen value. Instead of 'give me rows 10,001 to 10,010', you say 'give me the 10 rows where created_at < $lastCursor'. This is a range query on an indexed column — O(log N) regardless of how many pages deep you are. The cursor is an opaque token (typically a base64-encoded value of the sort key, or a combination of sort key + primary key for stability) that the client sends with each request. The server decodes it, uses it in the WHERE clause, and returns the next page along with the next cursor.
Offset Pagination Performance (1M row table):
─────────────────────────────────────────────────
Page 1 (OFFSET 0): ~10ms ✓
Page 10 (OFFSET 90): ~12ms ✓
Page 100 (OFFSET 900): ~40ms ⚠
Page 1000 (OFFSET 9,000): ~400ms ✗
Page 5000 (OFFSET 49,000): ~2000ms ✗ TIMEOUT RISK
Cursor Pagination Performance (1M row table):
─────────────────────────────────────────────────
Any page depth: ~5ms ✓ O(log N)
Page 1: WHERE TRUE LIMIT 10 → index scan start
Page 1000: WHERE (created_at, id) < ($1, $2) LIMIT 10
→ index seek to cursor position, then 10 rows
SQL comparison:
Offset: SELECT * FROM invoices ORDER BY created_at DESC LIMIT 10 OFFSET 9900
↑ must traverse 9,900 rows to find start
Cursor: SELECT * FROM invoices
WHERE (created_at, id) < ('2024-03-15 10:23:44', 1234)
ORDER BY created_at DESC, id DESC LIMIT 10
↑ seeks directly to cursor position in indexFrom my experience implementing cursor pagination in ERP APIs: use a composite cursor when sorting by a non-unique column like `created_at`. If two invoices were created at the exact same millisecond, a single-field cursor would be ambiguous and pages could overlap or skip records. Compose the cursor from `(created_at, id)` — sort by `created_at DESC, id DESC`, encode both values into the cursor, and filter with `WHERE (created_at, id) < ($cursorDate, $cursorId)`. PostgreSQL supports row-value comparisons on indexes, making this efficient.
The NestJS implementation involves a cursor encoder/decoder utility, a modified query builder, and a standardized response shape. The response returns items (the current page), nextCursor (null if no more pages), and hasMore (boolean). The client stores the cursor and sends it on the next request. For admin interfaces that need jump-to-page functionality, you can offer offset pagination alongside cursor pagination — use cursor for the 'next page' button and offset only for the page number input.
The cursor value should be opaque to the client — it's an implementation detail. Encode the raw values as base64 JSON: `Buffer.from(JSON.stringify({created_at: lastItem.createdAt, id: lastItem.id})).toString('base64url')`. The `base64url` variant avoids padding characters that can cause issues in query strings. Decode and validate on the server before using in a SQL query — always validate cursor values to prevent SQL injection and handle the case where the cursor points to a deleted record (return an empty page gracefully).
// cursor-pagination.util.ts
export function encodeCursor(createdAt: Date, id: number): string {
return Buffer.from(
JSON.stringify({ created_at: createdAt.toISOString(), id })
).toString('base64url')
}
export function decodeCursor(cursor: string): { created_at: string; id: number } {
try {
return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'))
} catch {
throw new Error('Invalid cursor')
}
}
// invoices.service.ts — NestJS cursor pagination
@Injectable()
export class InvoicesService {
constructor(private readonly dataSource: DataSource) {}
async findAll(limit: number, cursor?: string) {
const take = Math.min(limit, 100) // cap page size
let cursorWhere = ''
const params: unknown[] = [take + 1] // fetch +1 to detect hasMore
if (cursor) {
const { created_at, id } = decodeCursor(cursor)
cursorWhere = 'AND (created_at, id) < ($2, $3)'
params.push(created_at, id)
}
const rows = await this.dataSource.query(
`SELECT id, invoice_number, amount, created_at
FROM invoices
WHERE 1=1 ${cursorWhere}
ORDER BY created_at DESC, id DESC
LIMIT $1`,
params
)
const hasMore = rows.length > take
const items = hasMore ? rows.slice(0, take) : rows
const lastItem = items[items.length - 1]
return {
items,
hasMore,
nextCursor: hasMore && lastItem
? encodeCursor(new Date(lastItem.created_at), lastItem.id)
: null,
}
}
}Standard cursor pagination is unidirectional — you can only go forward. For a 'previous page' button, implement bi-directional cursors: maintain both a nextCursor and a prevCursor in each response. The prevCursor is derived from the first item in the current page rather than the last. The previous page query reverses the sort order and cursor direction. This doubles the complexity of your implementation but enables full prev/next navigation while preserving O(log N) performance in both directions.
The main trade-off of cursor pagination is that you can't jump to page 50 directly — you have to paginate sequentially. For most user interfaces (infinite scroll, 'load more' buttons, next/previous navigation), this is perfectly fine. But for admin UIs with page number inputs, users expect to type '50' and jump to page 50. If your product requires arbitrary page jumping, you either need offset pagination (accepting the performance cost), a hybrid approach (cursor for sequential navigation, offset for jump), or a denormalized count with offset for the specific use case.
GraphQL's Relay Connection Specification defines a standardized cursor pagination pattern: a `Connection` type with `edges` (array of `{node, cursor}`), `pageInfo` ({hasPreviousPage, hasNextPage, startCursor, endCursor}), and `totalCount`. Implementing this in NestJS with @nestjs/graphql gives your API a standard interface that any GraphQL client can work with without knowing your cursor implementation. Many GraphQL client libraries (Apollo, Relay) have built-in support for Relay connections, providing automatic pagination state management.
Cursor pagination is fast only if your sort column has an index. For `ORDER BY created_at DESC, id DESC`, create a composite index: `CREATE INDEX idx_invoices_created_id ON invoices (created_at DESC, id DESC)`. PostgreSQL can use this index for both the ORDER BY and the WHERE clause cursor filter, eliminating sequential scans entirely. If you sort by multiple columns dynamically (user can choose sort field), you'll need indexes on each sortable column — or accept that some sort combinations will fall back to sequential scans on large tables.