DocsHub
Mongoose

Pagination

Learn how to implement pagination in Mongoose using skip/limit and cursor-based pagination, with a reusable pagination helper.

Pagination

When a list grows — students, courses, enrollments — you cannot return everything in one response. Pagination breaks results into pages, returning a manageable chunk at a time.

There are two common approaches:

  • Skip/limit pagination — simple, works well for small to medium datasets, supports page numbers
  • Cursor-based pagination — faster on large datasets, but no page numbers — only "next" and "previous"

Skip/Limit Pagination

This is the approach we have used throughout this guide — .skip() and .limit().

Basic Implementation

async function getStudentsPage(page = 1, pageSize = 10) {
  const skip = (page - 1) * pageSize;

  const students = await Student
    .find({ enrolled: true })
    .sort({ name: 1 })
    .skip(skip)
    .limit(pageSize);

  return students;
}

const page1 = await getStudentsPage(1); // students 1-10
const page2 = await getStudentsPage(2); // students 11-20
const page3 = await getStudentsPage(3); // students 21-30

Returning Page Information

A real pagination response usually includes metadata — total count, total pages, current page:

async function getStudentsPage(page = 1, pageSize = 10, filter = {}) {
  const skip = (page - 1) * pageSize;

  const [students, totalCount] = await Promise.all([
    Student
      .find(filter)
      .sort({ name: 1 })
      .skip(skip)
      .limit(pageSize),
    Student.countDocuments(filter)
  ]);

  const totalPages = Math.ceil(totalCount / pageSize);

  return {
    data: students,
    pagination: {
      currentPage: page,
      pageSize: pageSize,
      totalCount: totalCount,
      totalPages: totalPages,
      hasNextPage: page < totalPages,
      hasPrevPage: page > 1
    }
  };
}

Usage

const result = await getStudentsPage(2, 10, { grade: "10th" });

console.log(result.data);       // array of 10 students
console.log(result.pagination); 
// {
//   currentPage: 2,
//   pageSize: 10,
//   totalCount: 45,
//   totalPages: 5,
//   hasNextPage: true,
//   hasPrevPage: true
// }

Promise.all() runs the count query and the find query in parallel — they do not depend on each other, so there is no need to wait for one before starting the other.

In an Express Route

app.get('/students', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const pageSize = parseInt(req.query.limit) || 10;
  const grade = req.query.grade;

  const filter = { enrolled: true };
  if (grade) filter.grade = grade;

  const result = await getStudentsPage(page, pageSize, filter);

  res.json(result);
});
GET /students?page=2&limit=10&grade=10th

The Problem with Large Skip Values

Skip/limit pagination works great for small to medium collections. But there is a problem — .skip() does not jump directly to the right place. MongoDB has to count through every skipped document before it can start returning results.

// Page 1 — fast, skip(0)
db.students.find().sort({ name: 1 }).skip(0).limit(10)

// Page 1000 — slow, skip(9990)
// MongoDB reads through 9990 documents before returning anything
db.students.find().sort({ name: 1 }).skip(9990).limit(10)

For a small school system with a few hundred students, this is not a problem. But for collections with millions of documents — activity logs, messages, large product catalogs — deep pagination with .skip() becomes very slow.


Cursor-Based Pagination

Instead of "skip N documents", cursor-based pagination says "give me documents after this specific point". The "point" is usually the _id of the last document on the current page.

How It Works

Page 1find().sort({_id: 1}).limit(10) Returns 10 docsLast _id: 'abc123' Page 2find({_id: {$gt: 'abc123'}}).sort({_id: 1}).limit(10) Returns next 10 docsLast _id: 'xyz789' Page 3find({_id: {$gt: 'xyz789'}}).sort({_id: 1}).limit(10)

Each page's query uses an indexed field (_id) to jump directly to the right starting point — no counting through skipped documents.

Implementation

async function getStudentsCursor(cursor = null, pageSize = 10, filter = {}) {
  const query = { ...filter };

  // If a cursor is provided, only get documents after it
  if (cursor) {
    query._id = { $gt: new mongoose.Types.ObjectId(cursor) };
  }

  const students = await Student
    .find(query)
    .sort({ _id: 1 })
    .limit(pageSize);

  // The cursor for the NEXT page is the _id of the last document
  const nextCursor = students.length > 0
    ? students[students.length - 1]._id
    : null;

  return {
    data: students,
    nextCursor: nextCursor,
    hasMore: students.length === pageSize
  };
}

Usage

// First page — no cursor
const page1 = await getStudentsCursor(null, 10, { enrolled: true });
console.log(page1.data.length);    // 10
console.log(page1.nextCursor);     // ObjectId of the 10th student

// Next page — pass the cursor from the previous page
const page2 = await getStudentsCursor(page1.nextCursor, 10, { enrolled: true });

In an Express Route

app.get('/students/feed', async (req, res) => {
  const cursor = req.query.cursor || null;
  const pageSize = parseInt(req.query.limit) || 10;

  const result = await getStudentsCursor(cursor, pageSize, { enrolled: true });

  res.json(result);
});
GET /students/feed
GET /students/feed?cursor=64a1f2c3e4b0a1b2c3d4e5f6

Sorting by a Different Field with Cursor Pagination

If you sort by something other than _id — like name — the cursor needs to track both the sort field's value AND _id (to break ties when multiple documents have the same name):

async function getStudentsByNameCursor(cursor = null, pageSize = 10) {
  const query = {};

  if (cursor) {
    // cursor is { name, _id } from the last document
    query.$or = [
      { name: { $gt: cursor.name } },
      { name: cursor.name, _id: { $gt: new mongoose.Types.ObjectId(cursor._id) } }
    ];
  }

  const students = await Student
    .find(query)
    .sort({ name: 1, _id: 1 })
    .limit(pageSize);

  const last = students[students.length - 1];
  const nextCursor = last ? { name: last.name, _id: last._id } : null;

  return {
    data: students,
    nextCursor: nextCursor,
    hasMore: students.length === pageSize
  };
}

This requires a compound index to perform well:

db.students.createIndex({ name: 1, _id: 1 })

This pattern — $gt on the sort field, OR same value with $gt on _id — is called keyset pagination. It correctly handles ties (multiple documents with the same name) by using _id as a tiebreaker, since _id is always unique.


Skip/Limit vs Cursor-Based — When to Use Which

Skip/LimitCursor-Based
Page numbers✅ Yes — "go to page 5"❌ No — only next/previous
Performance on deep pages❌ Slow — counts through skipped docs✅ Fast — uses index directly
Jump to arbitrary page✅ Yes❌ No
Implementation complexitySimpleSlightly more complex
Best forAdmin panels, small-medium lists, "page 1, 2, 3" UIInfinite scroll feeds, large datasets, APIs

School System Examples

Admin student list — skip/limit with page numbers

async function getStudentListPage(page = 1, pageSize = 20, grade = null) {
  const filter = { enrolled: true };
  if (grade) filter.grade = grade;

  const skip = (page - 1) * pageSize;

  const [students, totalCount] = await Promise.all([
    Student
      .find(filter)
      .select("name grade age address.city")
      .sort({ name: 1 })
      .skip(skip)
      .limit(pageSize),
    Student.countDocuments(filter)
  ]);

  return {
    data: students,
    pagination: {
      currentPage: page,
      pageSize,
      totalCount,
      totalPages: Math.ceil(totalCount / pageSize)
    }
  };
}

// Used in an admin dashboard with page number buttons
const result = await getStudentListPage(3, 20, "10th");

Activity feed / attendance log — cursor-based

async function getAttendanceFeed(cursor = null, pageSize = 20) {
  const query = {};

  if (cursor) {
    query._id = { $lt: new mongoose.Types.ObjectId(cursor) }; // newest first
  }

  const records = await Attendance
    .find(query)
    .sort({ _id: -1 }) // newest first
    .limit(pageSize)
    .populate("studentId", "name grade");

  const nextCursor = records.length > 0
    ? records[records.length - 1]._id
    : null;

  return {
    data: records,
    nextCursor,
    hasMore: records.length === pageSize
  };
}

// Used in an infinite-scroll attendance feed
const firstBatch = await getAttendanceFeed();
const nextBatch = await getAttendanceFeed(firstBatch.nextCursor);

Quick Reference

// Skip/limit pagination
const skip = (page - 1) * pageSize;
const data = await Model.find(filter).sort(sort).skip(skip).limit(pageSize);
const total = await Model.countDocuments(filter);
const totalPages = Math.ceil(total / pageSize);

// Cursor-based pagination
const query = cursor ? { _id: { $gt: cursor } } : {};
const data = await Model.find(query).sort({ _id: 1 }).limit(pageSize);
const nextCursor = data.length ? data[data.length - 1]._id : null;

For most school system features — admin lists, search results, reports — skip/limit pagination with page numbers is the better choice because users expect to jump to specific pages. Reserve cursor-based pagination for feeds that scroll continuously, like an activity log or notification feed, where "page 5" has no meaning to the user anyway.

On this page