Skip to main content

Command Palette

Search for a command to run...

MongoDB Pagination: From find() Hell to skip() Problems to Cursor Solutions

Updated
5 min read
R

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:

  1. Starts at document 1

  2. Walks through documents 1, 2, 3... all the way to 19,980

  3. 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.