Monitoring MongoDB Performance

Every production MongoDB deployment eventually hits a slow query, a runaway operation, or a server using more memory than expected. Monitoring is how you catch these problems before your users do, and diagnose them quickly once they happen. MongoDB ships with several built-in tools — command-line utilities, server introspection commands, and a query profiler — that together give you a complete picture of throughput, latency, memory usage, and index effectiveness.

Overview / How it works

MongoDB monitoring covers four broad areas: throughput (operations per second), latency (how long individual operations take), resource usage (memory, disk I/O, connections), and index effectiveness (whether queries use an index or fall back to a full collection scan).

The server tracks cumulative counters in db.serverStatus() — things like opcounters (inserts, queries, updates, deletes, commands since the process started), connections, and mem (resident and virtual memory). These are running totals, not instantaneous rates, which matters for how you read them (see Common Mistakes below).

Underneath every read and write sits the WiredTiger storage engine, which keeps a cache of frequently used data pages in memory. By default this cache is sized to 50% of (RAM minus 1 GB), with a 256 MB floor. As long as your working set — the data and indexes actively touched by queries — fits in that cache, reads are fast. Once it doesn’t, WiredTiger starts evicting pages and pulling from disk more often, and latency climbs even though nothing in your query changed. Watching mem.resident against total system RAM is an early warning sign of this pressure.

For any individual query, the query planner looks at the available indexes, generates candidate plans, and picks a winning plan based on which one does the least work (fewest documents examined) during a short trial period. You can see exactly which plan won, and how efficient it actually was, with explain(). If no usable index exists, the plan is a COLLSCAN — a full walk of every document in the collection. If an index is used, the plan is an IXSCAN followed by a FETCH to pull the full document for each matching index key.

For a live view of what’s running right now, db.currentOp() queries an in-memory table the server maintains of all active operations, including how long each has been running (secs_running). You can pair it with db.killOp(opid) to terminate a runaway operation — safe to do because a single document write is already atomic, so there’s no partial write to clean up.

Finally, the database profiler logs individual operations (with their exact timing and query shape) to a capped collection called system.profile, and the $indexStats aggregation stage reports per-index usage counters since the last restart — both are essential for finding slow queries and unused indexes after the fact, not just in the moment.

Syntax

The table below summarizes the main monitoring tools and when to reach for each one.

Tool What it shows Typical use
mongostat Live ops/sec, memory, connections, replication lag (terminal) Quick health check from the command line
mongotop Time spent reading/writing per collection (terminal) Find which collection is hot right now
db.serverStatus() Full server metrics snapshot: opcounters, connections, mem, wiredTiger cache Scripted polling, dashboards
db.currentOp() Currently running operations and their duration Find and kill a long-running or blocked operation
cursor.explain(verbosity) Winning query plan and execution stats for one query Diagnose a slow query, confirm index usage
db.setProfilingLevel() / system.profile Log of slow (or all) operations, persisted Find slow queries after the fact
$indexStats aggregation stage Per-index access counters since last restart Find unused indexes worth dropping

General form for the two most common commands:

db.<collection>.find(<query>).explain(<verbosity>);
// verbosity: "queryPlanner" (default), "executionStats", or "allPlansExecution"

db.setProfilingLevel(<level>, { slowms: <ms>, sampleRate: <0-1> });
// level: 0 = off, 1 = log slow ops only, 2 = log every op

Examples

Example 1: Diagnosing a slow query with explain()

Suppose db.orders has 500,000 documents and a query filtering on status feels slow. Run it with executionStats verbosity first:

db.orders.find({ status: "shipped" }).explain("executionStats");

Output:

{
  queryPlanner: {
    winningPlan: {
      stage: 'COLLSCAN',
      filter: { status: { '$eq': 'shipped' } },
      direction: 'forward'
    }
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 4210,
    executionTimeMillis: 138,
    totalKeysExamined: 0,
    totalDocsExamined: 500000
  }
}

The COLLSCAN stage and totalDocsExamined: 500000 confirm every document was checked to find 4,210 matches — a huge waste. Add an index on the filtered field and re-run:

db.orders.createIndex({ status: 1 });
db.orders.find({ status: "shipped" }).explain("executionStats");

Output:

{
  queryPlanner: {
    winningPlan: {
      stage: 'FETCH',
      inputStage: {
        stage: 'IXSCAN',
        indexName: 'status_1',
        direction: 'forward'
      }
    }
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 4210,
    executionTimeMillis: 6,
    totalKeysExamined: 4210,
    totalDocsExamined: 4210
  }
}

Now the plan is IXSCANFETCH, totalDocsExamined dropped to match nReturned exactly, and execution time fell from 138ms to 6ms. The ratio of documents examined to documents returned is the single most useful number in an explain output — the closer to 1:1, the more efficient the query.

Example 2: Catching slow queries automatically with the profiler

Rather than guessing which queries are slow, turn on the profiler so MongoDB logs anything over a threshold for you:

db.setProfilingLevel(1, { slowms: 100 });

Output:

{ was: 0, slowms: 100, sampleRate: 1, ok: 1 }

After running your application for a while, query the (capped) system.profile collection for the slowest recent entries:

db.system.profile.find({ millis: { $gt: 100 } }).sort({ ts: -1 }).limit(5);

Output:

[
  {
    op: 'query',
    ns: 'mydb.orders',
    millis: 412,
    planSummary: 'COLLSCAN',
    command: { find: 'orders', filter: { customerEmail: 'a@example.com' } },
    ts: ISODate('2026-08-04T10:22:11.000Z')
  }
]

This single document tells you the collection, the exact filter used, the plan (COLLSCAN — a missing index), and how long it took — enough to reproduce and fix the problem without having to catch it live.

Example 3: Finding a runaway operation and an unused index

To see what’s currently running longer than expected:

db.currentOp({ active: true, secs_running: { $gt: 5 } });

Output:

{
  inprog: [
    {
      opid: 445821,
      active: true,
      secs_running: 47,
      ns: 'mydb.orders',
      op: 'update',
      command: { q: {}, u: { '$set': { archived: true } }, multi: true }
    }
  ]
}

An unfiltered updateMany touching every document is running for 47 seconds and climbing — a good candidate to kill with db.killOp(445821) and rewrite with a proper filter or batching.

Separately, to find indexes nobody is using (they still cost write overhead and disk space):

db.orders.aggregate([{ $indexStats: {} }]);

Output:

[
  { name: 'status_1', accesses: { ops: 184203 } },
  { name: 'legacyFlag_1', accesses: { ops: 0 } }
]

legacyFlag_1 has zero accesses since the last restart — worth investigating as a drop candidate.

How it works step by step

When you run a query, the planner first checks the index catalog for candidates whose keys overlap the query’s filter fields. If more than one index looks usable, it runs a short competition between them (or uses a cached winning plan from a previous identical query shape) and picks the plan that examined the fewest documents to produce its results. The winning plan is then executed: an IXSCAN walks the index’s B-tree structure to find matching keys, then a FETCH stage retrieves the full BSON document for each match from the WiredTiger storage engine — pulling from cache if the page is resident, or from disk if not. A COLLSCAN skips the index step entirely and walks every document in the collection in storage order.

For writes, once a document is written, an entry is appended to the oplog (a capped collection replicated to secondaries). Secondaries apply oplog entries asynchronously, which is why rs.printSecondaryReplicationInfo() or db.serverStatus().repl matter for monitoring — a lagging secondary means reads with readPreference: "secondary" could return stale data.

Common Mistakes

Mistake 1: Reading opcounters as an instantaneous rate

db.serverStatus().opcounters is a cumulative counter since the process last started, not a per-second rate:

db.serverStatus().opcounters;
// { insert: 582034, query: 1923044, update: 88221, delete: 4021, getmore: 291, command: 902 }
// Wrong: "we're doing 1.9 million queries a second!"

To get an actual rate, sample the counter twice and divide by the elapsed time:

const before = db.serverStatus().opcounters;
sleep(10000);
const after = db.serverStatus().opcounters;
print((after.query - before.query) / 10, "queries/sec");

Mistake 2: Leaving the profiler at level 2 in production

db.setProfilingLevel(2); // logs EVERY operation, forever — wrong

Level 2 logs every single operation with no threshold, adding real overhead to every request and quickly cycling through the capped system.profile collection (default 1 MB, oldest entries silently dropped). Use level 1 with a threshold tied to your app’s latency SLA, and remember to turn it off when you’re done investigating:

db.setProfilingLevel(1, { slowms: 100 });
// ... investigate ...
db.setProfilingLevel(0);

Mistake 3: Assuming IXSCAN always means an efficient query

An index is used doesn’t automatically mean it’s selective. Indexing a low-cardinality boolean field is a common trap:

db.orders.createIndex({ active: 1 });
db.orders.find({ active: true }).explain("executionStats");

Output:

{
  executionStats: {
    nReturned: 249102,
    totalKeysExamined: 249102,
    totalDocsExamined: 249102,
    executionTimeMillis: 210
  }
}

The plan is an IXSCAN, but with only two possible values the index barely narrows anything — it still examines nearly half the collection. Always check totalDocsExamined relative to collection size, not just the stage name.

Best Practices

  • Sample serverStatus() metrics on an interval and graph the deltas — never treat a single snapshot as a live rate.
  • Run explain("executionStats") on every new or suspect query before shipping it, and check that totalDocsExamined is close to nReturned.
  • In production, run the profiler at level 1 with a slowms tied to your actual latency budget, not level 2.
  • Watch mem.resident against total system RAM as an early signal of WiredTiger cache pressure.
  • Monitor replication lag on secondaries, not just primary uptime — a lagging secondary silently serves stale reads.
  • Periodically review $indexStats output and drop indexes with near-zero access counts; every index adds write overhead.
  • For anything beyond a single developer poking at mongostat, forward these metrics to MongoDB Atlas monitoring, Ops Manager, or a Prometheus exporter with Grafana, and set real alert thresholds instead of eyeballing dashboards.

Practice Exercises

  • Find (or create) a collection with 100,000+ documents and a field with no index. Run explain("executionStats") filtering on that field, note the COLLSCAN and totalDocsExamined, then create an index and compare the new totalKeysExamined and executionTimeMillis.
  • Enable the profiler with slowms: 50, run several queries including one intentionally unindexed one, then query system.profile for entries with millis > 50 and identify the slow query’s planSummary.
  • Run db.<collection>.aggregate([{ $indexStats: {} }]) on a busy collection and list any index whose accesses.ops is at or near zero — a candidate for dropping.

Summary

  • mongostat and mongotop give a quick terminal-level view of live throughput and hot collections.
  • db.serverStatus() returns cumulative counters — sample twice and diff to get real rates.
  • db.currentOp() plus db.killOp() let you find and stop a runaway operation safely.
  • explain("executionStats") is the primary tool for diagnosing a slow query: compare totalDocsExamined to nReturned, and check for COLLSCAN vs IXSCAN.
  • The database profiler logs slow operations to system.profile for after-the-fact analysis — use level 1 with a threshold, not level 2, in production.
  • $indexStats reveals which indexes are actually used, helping you find drop candidates.
  • Watch memory (WiredTiger cache pressure) and replication lag as leading indicators, not just query latency.