Ch. 12 · MongoDB

MongoDB Capped Collections

Size a capped collection for a recent-errors feed, follow it with a tailable cursor, and see why TTL indexes suit time-based retention better.

~6 min readintermediateupdated Oct 19, 2026

A capped collection is MongoDB’s built-in ring buffer. You give it a size in bytes, it stores documents in the order they arrive, and when it’s full the oldest documents are removed to make room for new ones. No cleanup job, no index to expire things, no unbounded growth.

That sounds perfect for logs, and for a narrow set of uses it is. The interview question is usually the opposite one: when shouldn’t you use one? Answering it well means knowing exactly what a capped collection guarantees and what it costs.

The mongosh output below is illustrative, written to match MongoDB 7.0 and 8.0 behaviour as the manual describes it, not pasted from a session.

Creating one and what the numbers mean

Capped collections must be created explicitly. Inserting into a missing collection always creates an ordinary one.

db.createCollection("recent_errors", { capped: true, size: 50 * 1024 * 1024, max: 100000 })
JavaScript
  • size is required and is in bytes: 52,428,800 here. MongoDB rounds it up to a multiple of 256 and preallocates the space.
  • max is an optional document count. Whichever limit is hit first wins, and in practice that’s usually size. If 100,000 documents of 800 bytes each would need 80 MB, the 50 MB limit kicks in at around 65,000 documents and max never matters.

You can confirm the settings:

db.recent_errors.isCapped()
// true
db.recent_errors.stats().maxSize
// 52428800
JavaScript

Since MongoDB 6.0 you can change either limit in place with collMod, which used to require copying the data into a new collection:

db.runCommand({ collMod: "recent_errors", cappedSize: 100 * 1024 * 1024 })
db.runCommand({ collMod: "recent_errors", cappedMax: 200000 })
JavaScript

Shrinking cappedSize below the current data size doesn’t delete anything immediately; the excess oldest documents go on the next insert.

Insertion order and natural order

A query with no sort on a capped collection returns documents in insertion order, oldest first. That’s called natural order, and you can reverse it cheaply to read the newest documents, like tail on a log file:

db.recent_errors.find({ service: "checkout" }).sort({ $natural: -1 }).limit(20)
JavaScript

There’s one caveat the manual spells out: with several concurrent writers, MongoDB doesn’t guarantee that natural order matches the order in which writes happened. If you need exact ordering across writers, store a timestamp or sequence and sort on it.

Capped collections do have an _id index by default, and you can add secondary indexes, but every index adds write cost to a collection that exists to absorb writes quickly.

Following new documents with a tailable cursor

The feature that keeps capped collections relevant is the tailable cursor. A normal cursor closes once it has returned every match. A tailable cursor stays open at the end of the collection and returns new documents as they’re inserted, like tail -f. MongoDB’s own replication works this way: secondaries tail the primary’s oplog, which is a capped collection called local.oplog.rs.

With the Node.js driver:

const cursor = db.collection('recent_errors').find(
  { level: 'error' },
  { tailable: true, awaitData: true, maxAwaitTimeMS: 2000 }
);

for await (const doc of cursor) {
  console.log(new Date(), doc.service, doc.message);
}
JavaScript

tailable keeps the cursor open; awaitData makes each getMore wait up to maxAwaitTimeMS for new data instead of returning an empty batch immediately, which saves the client from spinning.

Things that go wrong in practice:

  • Tailing an empty collection. A tailable cursor needs a position. If the initial query returns nothing, the cursor can be closed straight away and the loop exits. Insert a placeholder document at creation time, or reopen the cursor in a retry loop.
  • Falling behind. If writers wrap the collection faster than the reader consumes, the document under the cursor gets overwritten and the cursor dies with a CappedPositionLost error. The reader then has to start again from the newest data, and anything in between is gone.
  • No index use. Tailable cursors scan in natural order. The first scan to find the starting point can be expensive on a big collection; after that, new documents are cheap to return.

For most application-level “react to new data” needs, change streams are now the better tool. They work on ordinary collections, survive failovers with resume tokens, and don’t require a capped collection at all.

The restrictions that rule them out

Capped collections cost you several things, and each one is a fair interview question:

  • Writes are serialized. The manual is explicit that inserts, updates and deletes on a capped collection perform worse under concurrency than on a normal collection. That’s the price of strict circular ordering.
  • They can’t be sharded. All the data and all the write load live on one replica set.
  • No writes inside transactions. Reads are fine; inserts and updates in a multi-document transaction are rejected. Before 8.0 you also couldn’t read them with readConcern: "snapshot".
  • $out can’t target them.
  • Updates are discouraged. An update that grows a document can push the collection past its allocated space. Very old releases rejected any size-changing update outright; current docs simply advise against updating capped data. Treat capped documents as write-once.

A worked example: sizing a recent-errors buffer

Say a support dashboard should show the last hour of application errors, with a live feed. Error documents average about 600 bytes including overhead, and the system produces around 25 per second normally, with spikes to 400 per second during incidents.

At the normal rate, an hour is 25 × 3,600 × 600 bytes ≈ 54 MB, so a 64 MB capped collection holds a bit over an hour. Now the spike: at 400 per second, 64 MB holds about 64 × 1,048,576 / (400 × 600) ≈ 280 seconds. During an incident, exactly when support most wants history, the buffer shrinks to under five minutes.

That’s the core trade-off. A capped collection bounds space, so its time window shrinks as volume grows. For a live feed of “what’s happening now” that’s acceptable, and the fixed footprint is an advantage: an error storm can’t fill the disk. For “the last hour, always”, it isn’t. You’d either size for the peak (400 × 3,600 × 600 bytes ≈ 864 MB) or use a normal collection with a TTL index on createdAt set to expireAfterSeconds: 3600, which bounds time and lets space vary.

Interview exercise: audit trail retention

A compliance requirement says user audit events must be kept for 30 days and then removed. A teammate proposes a 20 GB capped collection, since “that’s roughly 30 days at current volume”. What do you say?

Answer and reasoning

I’d push back and use a TTL index on a normal collection instead. The requirement is time-based, and a capped collection only guarantees space. If traffic doubles, 20 GB becomes 15 days of history and the oldest audit events silently disappear early. For an audit trail, that’s a compliance failure nobody gets alerted about. If traffic halves, data is kept longer than policy allows, which can be a problem of its own under data-protection rules.

A TTL index (createIndex({ createdAt: 1 }, { expireAfterSeconds: 2592000 })) deletes each document roughly 30 days after its timestamp, whatever the volume. The collection can also be sharded as audit volume grows, written inside transactions alongside the business change it records, and indexed normally for investigations. The costs are that storage isn’t capped (so monitor disk) and that the TTL monitor runs about once a minute, so deletion isn’t instant. I’d mention that MongoDB’s own manual recommends considering TTL indexes before capped collections, and reserve capped collections for bounded buffers like a live debug feed where losing old entries is acceptable by design.

See also

More in MongoDB

read ✓MongoDB · hard

MongoDB TTL Indexes and Eventual Expiration

How MongoDB's TTL monitor deletes expired documents, why they can linger past their deadline, and how to filter, tune and size expiry safely.

~5 min readread →
read ✓MongoDB · mid

MongoDB Bulk Writes

Batch a nightly upsert job with bulkWrite, pick ordered or unordered mode, and read writeErrors so partial failures don't get lost or retried.

~6 min readread →
read ✓MongoDB · hard

MongoDB Change Streams and Resume Tokens

Build an order-to-search sync on change streams, save resume tokens after processing, and recover when the oplog has rolled past you.

~6 min readread →
esc