MongoDB Pagination: From find() Hell to skip() Problems to Cursor Solutions
Full Stack Engineer specializing in the JavaScript Ecosystem (Next.js, Node.js, TypeScript). Expert in building scalable, production-grade web platforms (e.g., ChromaDec) with a focus on high-performance architecture. Additionally skilled in Enterprise Microservices (Java/Quarkus) and Cross-Platform Mobile development, bringing strict backend discipline to the modern web stack.
Let me walk you through this the right way, starting from the actual problem most developers face.
The Original Sin: Loading Everything with find()
When you're just starting out, you probably wrote something like this:
exports.getAllDishes = async (req, res) => {
try {
const dishes = await Dish.find({}).lean();
res.json({ data: dishes });
} catch (error) {
res.status(500).json({ message: error.message });
}
};
Simple, right? But here's the disaster waiting to happen.
Why Dish.find() Without Limits is Bad
When you call Dish.find({}) without any limits, MongoDB loads every single document from your collection into memory.
Got 10,000 dishes? That's 10,000 documents loaded at once.
Here's what breaks:
Server-Side Nightmares
Your Node.js server chokes. Loading all 10,000 dishes is a "costly operation" that blocks your single-threaded event loop. While one request is processing that massive dataset for 2+ seconds, every other user waits in line.
Memory overflows. Large datasets can hit memory limits and crash your server. One massive query can bring down your entire API.
Database strain. Your MongoDB instance has to read and transfer gigabytes of data for a single request.
Client-Side Disasters
Slow initial load. Users wait 5-10 seconds staring at a loading spinner while 10MB of JSON transfers over the network.
Mobile users suffer. Limited bandwidth means they're downloading megabytes of data they'll never even look at.
Browser crashes. Try rendering 10,000 items in the DOM—watch your browser tab freeze.
The Absurdity
You're transferring 100% of your data so users can view 0.1% of it on their screen. That's like ordering an entire library when you only want to read one book.
Enter skip() and limit(): The First Solution
This is where pagination saves the day:
exports.getAllDishes = async (req, res) => {
try {
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 20;
const skip = (page - 1) * limit;
const dishes = await Dish.find({})
.skip(skip)
.limit(limit)
.lean();
res.json({
data: dishes,
page: page
});
} catch (error) {
res.status(500).json({ message: error.message });
}
};
How skip() Solves the Problem
Smaller payloads. Instead of loading 10,000 dishes, you load just 20. Response drops from 10MB to 100KB.
Faster responses. Queries that took 2 seconds now complete in 50ms.
Better UX. Users see results immediately. They can navigate through manageable chunks instead of being overwhelmed.
Server efficiency. Your Node.js event loop can handle hundreds of concurrent requests smoothly because each request is lightweight.
Mobile-friendly. Reduced network traffic means your API works great even on slow 3G connections.
For page 1, page 2, even page 10—this works beautifully. Your users are happy. Your server is happy.
But then page 100 happens.
The skip() Performance Trap (10,000+ Documents)
Everything's running smoothly until users start paginating deep into your dataset. Suddenly, queries that were fast are now taking 10-20 seconds.
Here's the brutal truth about skip():
MongoDB Doesn't Skip—It Walks
When you write:
await Dish.find({})
.skip(19980) // Page 1000 with 20 items per page
.limit(20);
You'd think MongoDB uses indexes to jump directly to document 19,980, right?
Wrong.
MongoDB literally:
Starts at document 1
Walks through documents 1, 2, 3... all the way to 19,980
Only then returns the 20 documents you actually need
No shortcuts. No index jumping. Just pure, painful iteration through every skipped document.
Real-World Performance Horror
Let me hit you with actual numbers:
Case 1: A developer tried skipping 600,000 documents. Result? 18.5 minutes and 23.8GB of data read just to return 5 documents.
Case 2: Skipping just 800 documents increased execution time from 500ms to 20 seconds.
Watch Performance Decay
Here's the typical degradation pattern as page numbers increase:
Page 1 (skip 0): 500ms ✓ Perfect
Page 4 (skip 30): 700ms ⚠️ Slightly slower
Page 6 (skip 50): 900ms ⚠️ Getting worse
Page 80 (skip 800): 20 seconds ❌ User abandons site
This is a classic O(n) problem—performance degrades linearly with your skip value. The deeper users paginate, the worse it gets.
Why This Happens
MongoDB's skip() doesn't use indexes effectively. Even though _id has an index, skip() still needs to walk through documents one by one to maintain proper ordering and ensure it's skipping the right records.
The Solution: Cursor-Based Pagination
Instead of page numbers, use bookmarks.
Cursor-based pagination uses the _id field (or any indexed field) as a reference point:
exports.getAllDishes = async (req, res) => {
try {
const limit = Number(req.query.limit) || 20;
const lastId = req.query.cursor; // _id from last item of previous page
// Fetch items AFTER the cursor
const query = lastId ? { _id: { $gt: lastId } } : {};
const dishes = await Dish.find(query)
.sort({ _id: 1 })
.limit(limit)
.lean();
const nextCursor = dishes.length > 0
? dishes[dishes.length - 1]._id
: null;
res.json({
data: dishes,
nextCursor: nextCursor // Pass this to next request
});
} catch (error) {
res.status(500).json({ message: error.message });
}
};
Why This is Lightning Fast
The query { _id: { $gt: lastId } } leverages MongoDB's built-in index on _id. It jumps directly to the starting position without walking through documents.
Performance stays constant whether you're fetching the first 20 items or the 10,000th batch. It's O(1) instead of O(n).
The Trade-Off
Pros:
Constant performance at any depth
Scales to millions of documents
Perfect for infinite scroll, feeds, logs
Cons:
No "jump to page 50" button
Only supports next/previous navigation
Slightly more complex for frontend
The Hybrid Approach
Want page jumping AND performance? Combine both:
exports.getAllDishes = async (req, res) => {
try {
const limit = Number(req.query.limit) || 20;
const page = Number(req.query.page) || 1;
const cursor = req.query.cursor;
let query = {};
let skip = 0;
if (cursor) {
// Sequential navigation: use cursor (fast)
query = { _id: { $gt: cursor } };
} else if (page > 1) {
// Page jumping: use offset (slower but acceptable)
skip = (page - 1) * limit;
}
const dishes = await Dish.find(query)
.sort({ _id: 1 })
.skip(skip)
.limit(limit)
.lean();
res.json({
data: dishes,
nextCursor: dishes.length > 0 ? dishes[dishes.length - 1]._id : null
});
} catch (error) {
res.status(500).json({ message: error.message });
}
};
Strategy: Next/previous buttons use cursor (fast). Page number input uses skip (slower but rare).
The Golden Rules
For datasets under 10,000 records: skip() and limit() work perfectly fine.
For massive datasets with deep pagination: Cursor-based pagination is essential.
For mixed requirements: Use the hybrid approach.
Don't over-engineer for problems you don't have yet. But when your users start complaining about slow load times on page 50, you'll know exactly what to fix.