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-30Returning 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=10thThe 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
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=64a1f2c3e4b0a1b2c3d4e5f6Sorting 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/Limit | Cursor-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 complexity | Simple | Slightly more complex |
| Best for | Admin panels, small-medium lists, "page 1, 2, 3" UI | Infinite 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.