The Error
MongoServerError: operation exceeded time limit
You set maxTimeMS to protect your app from runaway queries. Now MongoDB is using it โ killing the query before it finishes. That part is working as intended. The problem is the query is too slow to begin with.
Nine times out of ten, it's one of three things: a missing index, a full collection scan on millions of documents, or an aggregation pipeline that wasn't designed to run against that much data.
Find the Slow Query First
Don't guess. Before changing anything, confirm exactly what's running slowly and why.
Check current operations
db.currentOp({ "active": true, "secs_running": { "$gt": 5 } })
This lists everything that's been running longer than 5 seconds. Find your query in the output and note the ns (namespace), command, and planSummary fields.
Check the slow query log
MongoDB logs any query exceeding the slow query threshold (100ms by default):
db.adminCommand({ getLog: "global" })
Or tail the log file directly:
tail -f /var/log/mongodb/mongod.log | grep -i "slow query"
Run explain() on the offending query
This is the most important diagnostic step. Run the query with explain("executionStats"):
db.orders.find({ status: "pending", userId: ObjectId("...") }).explain("executionStats")
Three things to look for in the output:
"stage": "COLLSCAN"โ MongoDB scanned the entire collection, no index useddocsExaminedvsnReturnedโ scanning 2,000,000 documents to return 12 results is the textbook index problemexecutionTimeMillisโ the actual wall-clock time
Fix 1: Add the Missing Index (Covers Most Cases)
A COLLSCAN result from explain() means MongoDB is reading every document in the collection. On a large dataset, that's slow regardless of hardware. Create an index that matches your query filter and sort:
// Single field
db.orders.createIndex({ status: 1 })
// Compound index matching the query shape
db.orders.createIndex({ status: 1, userId: 1 })
// Include sort field to avoid an in-memory sort step
db.orders.createIndex({ status: 1, userId: 1, createdAt: -1 })
Re-run explain() after creating the index. The stage should flip from COLLSCAN to IXSCAN, and docsExamined should drop sharply.
Building the index on a live production collection
On MongoDB 4.2 and earlier, index builds block all reads and writes on the collection. Use background: true to avoid an outage:
db.orders.createIndex({ status: 1, userId: 1 }, { background: true })
MongoDB 4.4+ builds indexes without blocking by default โ you don't need the option.
Fix 2: Raise maxTimeMS as a Short-Term Unblock
When production is down and you need breathing room, bump the timeout while you work on the real fix.
Node.js / Mongoose
// MongoDB Node.js driver
const result = await db.collection('orders')
.find({ status: 'pending' })
.maxTimeMS(30000) // 30 seconds
.toArray();
// Mongoose
const result = await Order.find({ status: 'pending' }).maxTimeMS(30000);
PyMongo
result = db.orders.find(
{"status": "pending"},
max_time_ms=30000
).to_list()
mongosh / mongo shell
db.orders.find({ status: "pending" }).maxTimeMS(30000)
A higher limit stops the immediate error but the underlying query still scans every document. Under load, that ties up connections and degrades everything else running on the server. Treat this as a patch, not a solution.
Fix 3: Optimize Heavy Aggregation Pipelines
Pipelines with $lookup, $group, or $unwind across large collections are a frequent culprit โ especially when stages aren't ordered to filter data early.
Add allowDiskUse for memory-heavy aggregations
db.orders.aggregate(
[
{ $match: { status: "pending" } },
{ $group: { _id: "$userId", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } }
],
{ allowDiskUse: true, maxTimeMS: 60000 }
)
Move $match and $limit as early as possible
// Bad โ $lookup runs on the full collection, then filters
[
{ $lookup: { from: "users", ... } },
{ $match: { status: "active" } }
]
// Good โ filter down first, $lookup operates on a smaller set
[
{ $match: { status: "active" } },
{ $lookup: { from: "users", ... } }
]
Moving a $match from stage 4 to stage 1 can reduce the working dataset by orders of magnitude before any expensive join or grouping runs.
Fix 4: Kill a Stuck Operation
Already running and blocking other queries? Kill it:
// Find the opid
db.currentOp({ "active": true })
// Kill by opid
db.killOp(12345)
Verify the Fix
- Re-run
explain("executionStats")โ confirm the stage is nowIXSCAN, notCOLLSCAN - Check that
docsExaminedis close tonReturned(e.g., examined 14, returned 12) - Run the actual query with your original
maxTimeMSvalue โ it should complete cleanly - Watch
db.currentOp()for a few minutes to confirm no long-running stragglers
Confirm the index is being used
db.orders.aggregate([
{ $indexStats: {} }
])
The new index should appear with a rising accesses.ops count as queries hit it.
Preventing This Going Forward
- Run explain() while writing queries, not after they break in production โ catching a COLLSCAN in development costs nothing; catching it at 2 AM costs a lot more
- Turn on the slow query profiler:
db.setProfilingLevel(1, { slowms: 100 })โ slow queries land insystem.profilefor review before they escalate - Follow the ESR rule for compound indexes โ Equality fields first, then Sort, then Range; field order in the index matters as much as which fields you include
- Paginate large result sets โ fetching 50,000 documents in a single call is slow no matter how good your indexes are; use
limit()and cursor-based pagination instead - Audit unused indexes periodically โ each unused index slows down every write; drop them with
db.collection.dropIndex()once confirmed idle

