Page-Based, Offset, or Cursor — Pick One
Pagination Patterns
Don't return fifty thousand rows. Page the response, and pick the pagination style that matches how the client will consume it.
What you'll learn
- Offset pagination with skip / take
- Cursor pagination for stable ordering
- Return next / prev links to the client
A list endpoint should never return “everything.” Page it. The question is which style of pagination to use — and the answer depends on what the client will do with the data.
Offset Pagination
The classic ?page=3&size=20 query. Easy to implement, easy for humans
to reason about, but slow on deep pages because the database has to scan
the rows it’s skipping.
@Get()
async list(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
) {
const [items, total] = await this.repo.findAndCount({
order: { createdAt: 'DESC' },
take: size,
skip: (page - 1) * size,
});
return {
items,
total,
page,
pageCount: Math.ceil(total / size),
};
} findAndCount returns the rows and the total in a single round trip.
Good for traditional “Page 1 of 12” UIs.
Cursor Pagination
For infinite scroll, social feeds, or anything where new rows appear at the top, offsets are wrong — they shift when data is inserted. Use a cursor: an opaque pointer to a specific row.
@Get()
async list(
@Query('cursor') cursor?: string,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size?: number,
) {
const where = cursor ? { id: LessThan(Number(cursor)) } : {};
const items = await this.repo.find({
where,
order: { id: 'DESC' },
take: size + 1,
});
const hasMore = items.length > size;
if (hasMore) items.pop();
return {
items,
nextCursor: hasMore ? items[items.length - 1].id : null,
};
} The take: size + 1 trick is how you detect “is there a next page” with
one query: peek one extra row, then drop it before returning.
Returning Links
Whichever style you pick, give the client enough metadata to call you
again. For offset: total, page, pageCount. For cursor: nextCursor
(and prevCursor if you support going back).
If you want to be properly REST-y, return RFC 5988 Link headers:
res.setHeader(
'Link',
`<${url}?cursor=${nextCursor}>; rel="next"`,
); Which One?
- Offset — admin tables, search results, anything with a “Page X of Y” affordance.
- Cursor — infinite scroll, activity feeds, exports, any list where new rows keep landing.
When in doubt, cursor. It’s more code up front but scales better and behaves correctly when data shifts under you.
Per-Environment Database Config →