Paginate Mongoose Queries with skip() and limit()
Use Mongoose skip() and limit() with countDocuments() for stable, index-aware page-based pagination in Node.js.
17 Feb 2026, 14:41 UTC

Problem and takeaway
You need to return a slice of matching documents from MongoDB through Mongoose and tell the client how many pages exist. Loading every matching document just to count them wastes memory, and skipping documents without a stable order can duplicate or omit rows when data changes. Use sort() on a stable unique field, skip() and limit() for the current page, and countDocuments() with the same filter for the total. Run both queries in parallel with Promise.all().
Desired outcome
Return one page of documents from a MongoDB collection through Mongoose, along with the total number of matching documents and enough metadata to render page controls. The client should know how many items are on the current page, the total count, the total page count, and whether previous and next pages exist.
Prerequisites
- A running MongoDB instance and an established Mongoose connection (Mongoose 6.x or 7.x assumed).
- A Mongoose model bound to the collection you want to page through.
- A filter object that matches the documents you want to count and return.
- Requested page number and page size from the caller.
Procedure
1. Normalize page and limit
Coerce the incoming values to integers and apply sane defaults. Capping limit prevents a client from requesting an excessively large result set.
const page = Math.max(1, parseInt(requestedPage, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(requestedLimit, 10) || 20));
const skip = (page - 1) * limit;
skip() tells MongoDB how many matching documents to discard before returning results. limit() caps the number of documents returned.
2. Choose a stable sort
Apply sort() on a field that does not change and is unique, such as _id. Without a deterministic order, documents can shift between pages when inserts or deletes happen, causing duplicates or omissions.
const sort = { _id: 1 };
If you paginate by timestamp, add _id as a tiebreaker so the order remains stable:
const sort = { createdAt: -1, _id: 1 };
3. Run the page query and count together
Use Promise.all() to execute the data query and countDocuments() in parallel instead of waiting for one to finish before starting the other. Both must use the same filter so the total matches the result set being sliced.
async function paginate(Model, { page = 1, limit = 20, filter = {} }) {
const safePage = Math.max(1, Number(page) || 1);
const safeLimit = Math.min(100, Math.max(1, Number(limit) || 20));
const skip = (safePage - 1) * safeLimit;
const sort = { _id: 1 };
const [documents, total] = await Promise.all([
Model.find(filter).sort(sort).skip(skip).limit(safeLimit).exec(),
Model.countDocuments(filter).exec(),
]);
const totalPages = Math.ceil(total / safeLimit);
return {
data: documents,
total,
page: safePage,
limit: safeLimit,
totalPages,
hasNextPage: safePage < totalPages,
hasPrevPage: safePage > 1,
};
}
countDocuments() returns the number of documents matching the filter, not the number returned on this page. Use it for page metadata, not for the array length.
4. Handle empty or out-of-range pages
If skip exceeds the total matching count, Mongoose returns an empty array. Return that empty array with the calculated metadata rather than throwing an error, so the client can render an empty page state.
Expected checks
- The returned data array contains no more than limit documents.
- total equals the result of running countDocuments() with the same filter outside the page function.
- When the collection is static, stepping through pages with skip and limit produces no repeated or missing documents.
Diagnostic decision: verify index usage
Run explain('executionStats') on the paginated query to confirm MongoDB uses an index. A COLLSCAN stage means the database read every document to apply the filter or sort.
await Model.find(filter).sort({ _id: 1 }).skip(skip).limit(limit).explain('executionStats');
Where to run: in a Node.js script or MongoDB shell against the same collection. Required permissions: read access to the collection and the explain command. Risk: explain does not modify data, but repeated execution on a large collection can add load.
If you see a scan stage, add an index that supports the sort and filter. For the example above, an ascending index on _id usually already exists because _id is indexed by default. For a createdAt sort, create an index on createdAt.
Limitations
- Deep pagination is expensive. A high skip value forces MongoDB to scan and discard all preceding matching documents before returning the page. Latency and CPU use grow as the offset increases.
- countDocuments() depends on indexes. An unindexed filter can trigger a collection scan, especially on large collections.
- Skip/limit is not safe for changing data without a stable sort. Documents inserted or deleted between requests can shift positions and appear on two pages or disappear entirely.
- No built-in paginate(). Core Mongoose does not provide a pagination method. Plugins such as mongoose-paginate-v2 add one, but they introduce their own API and behavior.
Practical check
Insert a known number of documents into a test collection, request page two with a small limit, and assert that the first document on page two is the document immediately after the last document on page one under the chosen sort order. Then increase skip to a large value and measure response time to observe the deep-pagination cost.
When to consider cursor-based pagination
If users frequently page far into a result set, replace skip() with a cursor condition. For example, after returning a page, send the last document's _id to the client. The next request queries { _id: { $gt: lastId } } with the same sort and limit. This avoids scanning and discarding earlier documents.
Recovery options
This operation only reads data, so there is no state to roll back. If the count query fails, return the documents without total and totalPages, or degrade to an estimated count. If the data query fails, return an HTTP 502 or equivalent and let the client retry.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.