Cursor-Based Pagination in Node.js: A Faster Way to Handle Large Datasets
Is your API struggling with pagination? Are your queries getting slower as your dataset grows? If you’re still using offset-based pagination, that might be the culprit. In this post, we’ll break down how cursor-based pagination works, why it’s more efficient for large datasets, and how to implement it step-by-step in a Node.js REST API.
What Is Cursor-Based Pagination?
Cursor-based pagination (also called keyset pagination) provides a more efficient way to load data by referencing a specific point (cursor) in a dataset rather than using an offset like page=3.
Here’s how it works:
- Client Request:
The client sends a GET request with either anextCursororprevCursor, plus an optionalpageSize. - Server Processing:
- The server parses the query parameters to determine:
- the cursor (starting point)
- the limit
- the sorting order
- It retrieves one extra record to see if more pages exist.
- The server parses the query parameters to determine:
- Server Response:
The server returns:- the results
nextCursor(if more results follow)prevCursor(if previous results exist)
The client then uses these cursors to navigate forward or backward through results.
When Should You Use Cursor-Based Pagination?
Cursor-based pagination works only on sequential columns—such as id, created_at, or timestamp.
The order of these columns matters. For example, if you always display results in ascending order, your queries and cursor logic must match that direction.
It’s ideal for:
- Infinite scrolling
- Real-time feeds
- Large datasets
- Avoiding performance issues caused by large offsets
🚀 Complete JavaScript Guide (Beginner + Advanced)
🚀 NodeJS – The Complete Guide (MVC, REST APIs, GraphQL, Deno)
How Cursor-Based Pagination Works Internally
Let’s assume you’re paginating tasks ordered by created_at in ascending order.
Paginating Forward (Next Page)
- Query for records where
created_atis greater than the cursor. - Fetch limit + 1 items.
- If you receive more than
limit, that means there are additional records:- The last item becomes the
nextCursor. - The extra record gets discarded.
- The last item becomes the
Determining Previous Page
If a nextCursor was used, you automatically know a previous page exists.
- The first record in the fetched set becomes the
prevCursor.
Paginating Backward (Previous Page)
- Query for records where
created_atis less than the cursor. - Order records in descending order.
- Fetch limit + 1 records.
- After determining cursors, reverse the list so results appear in the correct ascending order.
Implementing Cursor-Based Pagination in Node.js
In the example project (a task manager API), tasks and projects use CUIDs—which are not sequential.
So we use created_at as the cursor column.
Define Query & Result Types
src/data/repositories/repository.ts
export interface IQueryParameters {
limit: number;
nextCursor?: Date;
prevCursor?: Date;
}
export interface ITaskQueryResult {
tasks: Task[];
nextCursor?: Date;
prevCursor?: Date;
}
export interface IProjectQueryResult {
projects: Project[];
nextCursor?: Date;
prevCursor?: Date;
}
Create Base Repository Pagination Logic
src/data/repositories/BaseRepository.ts
export class BaseRepository {
protected getPaginationQueryParameters(query: IQueryParameters) {
const { limit, nextCursor, prevCursor } = query;
let cursor: Date | undefined;
let operator: "gt" | "lt" = "gt";
let sortOrder: "asc" | "desc" = "asc";
if (nextCursor) {
cursor = nextCursor;
operator = "gt"; // forward
sortOrder = "asc";
}
if (prevCursor) {
cursor = prevCursor;
operator = "lt"; // backward
sortOrder = "desc";
}
return { limit, cursor, operator, sortOrder };
}
protected getPaginationCursors(records: any[], limit: number, sortOrder: "asc" | "desc") {
let nextCursor: Date | undefined = undefined;
let prevCursor: Date | undefined = undefined;
const hasExtraRecord = records.length > limit;
if (hasExtraRecord) {
records.pop(); // remove extra
}
if (records.length > 0) {
if (sortOrder === "asc") {
prevCursor = records[0].created_at;
nextCursor = hasExtraRecord ? records[records.length - 1].created_at : undefined;
} else {
nextCursor = records[records.length - 1].created_at;
prevCursor = hasExtraRecord ? records[0].created_at : undefined;
}
}
return { nextCursor, prevCursor };
}
}
Implement Cursor Pagination in Task Repository
src/data/repositories/TaskRepository.ts
export class TaskRepository extends BaseRepository {
async listTasks(userId: string, params: IQueryParameters): Promise<ITaskQueryResult> {
const { limit, cursor, operator, sortOrder } =
this.getPaginationQueryParameters(params);
const where: any = {
userId,
...(cursor && { created_at: { [operator]: cursor } })
};
let tasks = await prisma.task.findMany({
where,
orderBy: { created_at: sortOrder },
take: limit + 1
});
const { nextCursor, prevCursor } = this.getPaginationCursors(tasks, limit, sortOrder);
// Reverse results if going backwards
if (sortOrder === "desc") {
tasks = tasks.reverse();
}
return { tasks, nextCursor, prevCursor };
}
}
Add Pagination Utilities
src/utils.ts
export const encodeBase64 = (value: string) =>
Buffer.from(value).toString("base64");
export const decodeBase64 = (value?: string) =>
value ? Buffer.from(value, "base64").toString("utf8") : undefined;
export function getPaginationParameters(req: Request): IQueryParameters {
const perPage = Number(req.query.perPage) || config.defaultPageSize;
const nextCursor = decodeBase64(req.query.nextCursor as string);
const prevCursor = decodeBase64(req.query.prevCursor as string);
return {
limit: perPage,
nextCursor: nextCursor ? new Date(nextCursor) : undefined,
prevCursor: prevCursor ? new Date(prevCursor) : undefined
};
}
Update Controller
src/controllers/task.controller.ts
export async function listTasks(req: Request, res: Response) {
const { limit, nextCursor, prevCursor } = getPaginationParameters(req);
const repoResult = await repository.listTasks(req.user.id, {
limit,
nextCursor,
prevCursor
});
res.json({
tasks: repoResult.tasks,
nextCursor: repoResult.nextCursor
? encodeBase64(repoResult.nextCursor.toISOString())
: null,
prevCursor: repoResult.prevCursor
? encodeBase64(repoResult.prevCursor.toISOString())
: null
});
}
Example Query Flow
Request first page
GET /tasks?perPage=3
Response:
{
"tasks": [...],
"nextCursor": "MTY5NjE0OTAwMDAwMA==",
"prevCursor": null
}
Request next page
GET /tasks?perPage=3&nextCursor=MTY5NjE0OTAwMDAwMA==
Response:
{
"tasks": [...],
"nextCursor": "MTY5NjE1MDAwMDAwMA==",
"prevCursor": "MTY5NjE0OTAwMDAwMA=="
}
Request previous page
GET /tasks?perPage=3&prevCursor=MTY5NjE0OTAwMDAwMA==
Edge Cases You Should Handle
1. Dynamic sort order
Letting users choose asc/desc requires adapting cursor logic dynamically.
2. Dynamic cursor columns
User sorting by updated_at or processed_at means cursor column must change too.
3. Duplicate cursor values
If multiple rows share the same timestamp, add a secondary tie-breaker:
orderBy: [
{ created_at: "asc" },
{ id: "asc" }
]
Encode cursor like:
`${created_at}|${id}`
4. Index your cursor column
If created_at isn’t indexed, cursor pagination won’t be efficient.
Final Thoughts
Cursor-based pagination is powerful — but it’s not always needed. In my experience only about 20% of projects truly benefit from cursor-based pagination. For most CRUD-style applications, offset-based pagination is simpler and perfectly adequate.