iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

sort / skip / limit

Sort and limit drive feeds, leaderboards, and paginated lists. sort() orders the cursor; skip()/limit() page through results. Index your sort key, prefer keyset pagination over deep skip.

sort, skip, limit, keyset pagination

EXAMPLE
// 1) Basic — newest first
await db.posts.find({})
    .sort({ createdAt: -1 })
    .limit(20)
    .toArray();

// 2) Multi-key sort — secondary order for ties
await db.users.find({})
    .sort({ city: 1, name: 1 })
    .toArray();
// Compound index that matches both keys: db.users.createIndex({ city: 1, name: 1 })

// 3) Skip + limit (offset pagination — easy, doesn't scale)
const page = 5, size = 20;
await db.posts.find({})
    .sort({ createdAt: -1 })
    .skip(page * size)
    .limit(size)
    .toArray();
// Skipping 1,000,000 docs reads + discards 1,000,000 — slow tail.

// 4) Keyset pagination — O(log n) regardless of page
// Page 1: newest 20
const first = await db.posts.find({})
    .sort({ createdAt: -1, _id: -1 })
    .limit(20)
    .toArray();
const last = first.at(-1);

// Page 2: anything older than `last`
const second = await db.posts.find({
    $or: [
        { createdAt: { $lt: last.createdAt } },
        { createdAt: last.createdAt, _id: { $lt: last._id } },
    ],
})
    .sort({ createdAt: -1, _id: -1 })
    .limit(20)
    .toArray();

// 5) Stable sort needs a tie-break
// Two docs with the same createdAt could swap positions across pages.
// Always include _id (or another unique field) in the sort to make it deterministic.
await db.posts.find({}).sort({ createdAt: -1, _id: -1 });

// 6) Top-N per user — aggregation
await db.orders.aggregate([
    { $match: { status: 'paid' } },
    { $sort:  { user_id: 1, total: -1 } },
    { $group: {
        _id:   '$user_id',
        top:   { $firstN: { input: '$$ROOT', n: 3 } },
    } },
]).toArray();

// 7) Skip in aggregation
await db.posts.aggregate([
    { $match: { status: 'published' } },
    { $sort:  { createdAt: -1 } },
    { $skip:  40 },
    { $limit: 20 },
]).toArray();

// 8) Sort with collation (case-insensitive, locale-aware)
await db.users.find({})
    .sort({ name: 1 })
    .collation({ locale: 'en', strength: 2 })   // 2 = case-insensitive
    .toArray();

// 9) Random sample — $sample
await db.posts.aggregate([
    { $match:  { tags: 'featured' } },
    { $sample: { size: 5 } },
]).toArray();
// O(N) shuffle; for large collections, use a precomputed 'random' field + sort.

// 10) Cursor with batchSize (network-efficient streaming)
const cursor = db.events.find({}).sort({ ts: -1 }).batchSize(1000);
for await (const doc of cursor) {
    await process(doc);
}

// 11) Indexes that serve sort + limit efficiently
// The leading prefix of the index must MATCH the sort.
// Sort { city: 1, name: 1 } needs index { city: 1, name: 1 } — not just { name: 1 }.

// Verify the index helps:
await db.posts.find({ status: 'published' })
    .sort({ createdAt: -1 })
    .limit(20)
    .explain('executionStats');
// Look for: stage 'IXSCAN' with 'sortPattern' aligned, no 'SORT' in executionStages

// 12) Common bugs
//   • Sort + skip on an UNINDEXED field — full collection scan, O(N log N) in-memory sort
//   • Forgetting _id tie-break — duplicates / gaps across pages
//   • Using offset for chat / activity feeds — every read scans more rows
//   • Sorting on a computed field — wrap in aggregation $addFields, but lose the index

Why it matters

Index the sort key + tie-break by _id; switch to keyset pagination as soon as offsets get big. skip(N) reads + discards N documents — great for the first page, painful for the 50th.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
db.customers.find().sort({ age: -1 }).skip(20).limit(20);
Try it Yourself »

Discussion

Loading…