cursor pagination in Node.js REST API

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:

  1. Client Request:
    The client sends a GET request with either a nextCursor or prevCursor, plus an optional pageSize.
  2. 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.
  3. 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_at is 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.

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_at is 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.

Share this article

Similar Posts