Cursor pagination with Drizzle

Drizzle cursor pagination with findByCursor: pass each nextCursor back for the next page. Deep pages cost the same as page 1 and stay stable under inserts.

For Drizzle cursor pagination, call findByCursor(filters, { limit, sortBy, cursor }). It returns { data, limit, nextCursor }; pass nextCursor back as cursor to get the next page, until it is null. Each page is a WHERE on the sort columns of the last row instead of an OFFSET, so page 500 costs the same as page 1, and a row inserted on an earlier page does not shift the next one. This is also called keyset pagination.

When to pick it

Use findByCursor for infinite scroll, exports, sync jobs and lists that grow while people read them. Use offset pagination with findAll when the UI shows page numbers or jumps to page 7.

How do I walk a list with findByCursor?

Start without a cursor, then pass each page's nextCursor until it comes back null.

posts-export.service.ts
let cursor: string | null = null;
do {
  const page = await this.postsService.findByCursor(
    { status: 'published' },
    { sortBy: ['-published_at'], limit: 50, cursor },
  );
  await handle(page.data);
  cursor = page.nextCursor;
} while (cursor);

Filters, options.search, select, relations, counts, the tenant scope, soft delete and row locks all work as they do in findAll.

What does findByCursor return?

The rows, the limit that was used, and the cursor of the next page.

query.interface.ts
interface CursorPage<T> {
  data: T[];
  limit: number;
  nextCursor: string | null; // null on the last page
  total?: number;            // only with count: true
}

There is no page number: a cursor is a position, not an index. The count query is skipped unless you pass count: true, because counting is the slow part of a large list. With count: true, total is the number of rows that match the filters, not the number left after the cursor.

Which options does findByCursor take?

The second argument takes these fields, all optional.

cursorOptional

string | null

The nextCursor of the previous page. Absent, null or an empty string means the first page.

Defaults to none

afterOptional

string | null

Alias of cursor. cursor wins when both are set.

Defaults to none

limitOptional

number

Rows per page, clamped to 1 to pagination.maxLimit (100 by default).

Defaults to pagination.defaultLimit (20)

sortByOptional

string | Array<string | { column: string; order?: 'asc' | 'desc' }>

Same shapes as findAll, belongs-to relation columns included.

Defaults to defaultSort, then the primary key

sortOrderOptional

'asc' | 'desc'

Direction for a single sortBy string without a - or + prefix.

Defaults to 'desc'

countOptional

boolean

Also run the count query and return total.

Defaults to false

How does the cursor work?

The cursor holds the sort values of the last row on the page, primary key last, plus the sort it was made for. The next query asks for rows that come after that position. With sortBy: ['views'] on a not-null column:

keyset query
WHERE (views > $1) OR (views = $1 AND id > $2)
ORDER BY views ASC, id ASC
LIMIT 51

How each part keeps pages correct:

PartWhy
Primary key as the last sort columnIt makes every position unique, so no row is repeated or skipped.
LIMIT limit + 1The extra row says whether there is a next page. It is dropped from data.
NULL handlingNULLs follow the database's own order: last ascending and first descending on Postgres, the other way round on MySQL. The comparison follows the same rule.
Timestamps read as textA JavaScript Date keeps milliseconds and Postgres keeps microseconds. Reading the value as text keeps the microseconds, so a row is never shown twice.
bigint valuesJSON cannot hold a bigint, so it travels as { "$b": "123" } and comes back as a bigint.

The cursor itself is base64url JSON, built in src/core/utils/cursor.ts. The cursor eyJzIjoibmFtZTphc2MsaWQ6YXNjIiwidiI6WyJDaGFkIiw1MF19 decodes to {"s":"name:asc,id:asc","v":["Chad",50]}: the sort signature and the last row's name and id.

Opaque, not secret

Anyone can decode a cursor, so it only ever holds values the rows already show. Do not sort by a column you would not return to the caller.

What happens with a bad cursor?

It is a 400 (BadRequestException). These all fail:

CursorMessage
Not a string, not base64url JSON, or the wrong number of valuesInvalid cursor
Made for a different sortBy or sortOrderThe cursor was made for a different sort; start again without one

Changing the sort mid-walk breaks the position, so start again from the first page.

How do I use cursor pagination over HTTP?

With parseQuery, a cursor (or after) key in the query string starts a keyset walk and sets isCursor. An empty ?cursor= means the first page.

requests
GET /countries?limit=20&cursor=
GET /countries?limit=20&cursor=eyJzIjoibmFtZTphc2MsaWQ6YXNjIiwidiI6WyJDaGFkIiw1MF19
GET /countries?limit=20&cursor=&count=true

One controller method can serve both kinds of list:

countries.controller.ts
@Get()
findAll(@Query() query: Record<string, unknown>) {
  const q = this.countries.parseQuery(query);
  return q.isCursor
    ? this.countries.findByCursor(q.filters, q.pagination, q.options)
    : this.countries.findAll(q.filters, q.pagination, q.options);
}

When should I use offset pagination instead?

When the UI shows page numbers or jumps to a given page. Keyset pages only go forward from a known position. For infinite scroll, exports, sync jobs and any list that grows while people read it, use the cursor.

findAll (offset)findByCursor (keyset)
Jump to page NYesNo
Cost of a deep pageGrows with the offsetSame as page 1
Rows inserted while pagingCan shift pagesNo effect on later pages
Count queryOn by default (withCount)Off by default (count)

Common questions

Next steps

Last updated View change history

On this page