Skip to content

Build1 publisher3 min readPublished

MongoDB's 16 MiB document cap stalls agents that keep the whole transcript in one record

MongoDB rejects the entire write once a document passes 16 MiB, a hard cap that stalls agents keeping every turn in one snapshot. Frameworks can swallow that rejection, so the defence is measuring documents with $bsonSize and moving transcripts out early.

The Engineer · Build desk

Illustration accompanying MongoDB's 16 MiB document cap stalls agents that keep the whole transcript in one record

What happened

  • In Mastra issue #21412, a DurableAgent snapshot outgrew the limit, the write was rejected, and the framework marked the snapshot failed, deleted it and left the stream stalled.
  • A $push with $each and a negative $slice appends and trims an array to its newest entries in one atomic update.
  • TTL indexes delete whole documents on a 60-second background pass and cannot trim an array inside a live session document.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure Application logs and HTTP monitoring will miss this failure, since the only error is the database's write rejection and the user sees a spinner.
  • constraint A count cap bounds entries, not bytes, so 200 retained results of 400 KB each still come to about 80 MB, nearly five times the ceiling.
  • decision With no setting able to raise the limit, teams keeping transcripts inside snapshot documents have to split messages into a per-session collection before long runs reach it.

MongoDB itself is loud about this. It rejects the whole write, and nothing truncated or partial reaches the collection [4]. The quiet part happens one layer up. The author of a dev.to post describes an agent that produced no stack trace and no failed HTTP call while its UI kept spinning [7]. "There was an error, all right. It wasn't in the app, it was in the database," the author wrote [8]. In the Mastra case the post summarizes, the framework recorded the rejection as a failed snapshot and then deleted it [5]. The issue closed as resolved in a later release [6]. The post does not say what that fix changed. The author argues the trap belongs to MongoDB, so it applies to anyone storing a growing conversation in one document [6].

I think whole-write rejection is the right server behaviour. A snapshot that resumed with half its history would be harder to debug than a stalled stream.

Turn count is a poor predictor of when a run fails. The post puts the threshold at a few hundred turns [10], but the bulk arrives in tool results: a read_file on a 400 KB log, a JSON response, an HTML page, a base64 blob [9]. At 400 KB each, about 42 results fill 16,777,216 bytes [1]. If the stored history holds a second copy of each, as the post says it usually does, that falls to about 21 [2].

Measurement is cheap. The server reports its ceiling as `maxBsonObjectSize` in the `hello` command, so a monitor can compare against the live value [2]. A `$project` with `$bsonSize` on `$$ROOT`, sorted descending and limited to ten, returns the largest documents in the snapshot collection [11]. Converting each document with `$objectToArray`, unwinding it and sizing each value shows which field is growing. According to the post it is usually the message history, then stored tool output [12]. I would run the first query on a schedule and alert well below the reported ceiling.

The first structural change I would make is the one the post leads with. The snapshot keeps state, a reference and a turn count, and messages move to their own collection keyed by session [13]. The growing array then lives outside the document that has to stay small [13].

The array cap is good API design. `$push` with `$each` and a negative `$slice` appends and trims to the newest entries in one atomic update; the post's example keeps 200 [14]. `$slice` counts entries, though, and 200 results of 400 KB each come to 80,000,000 bytes, about 4.8 times the limit [3]. A 200-entry cap holds only while the average entry stays under about 83,886 bytes, before anything else in the document is counted [4]. The modifier also requires `$each` [15]. "On its own it gets silently skipped, which is a fine way to spend an afternoon wondering why your array never shrinks," the author wrote [16].

For blobs, GridFS splits a file into 255 KiB chunks across `fs.files` and `fs.chunks` by default, and its own documentation says files under 16MB should stay in an ordinary document [17]. TTL indexes delete whole documents once a date field ages out, on a background pass every 60 seconds, and only on a single field [18]. They clear old sessions. They cannot trim an array inside a live one [18].

What to watch

  • What the Mastra release that closed issue #21412 changed: surfacing the rejected write, or moving message history out of workflow snapshots.
  • Whether other agent frameworks with MongoDB storage still keep full transcripts and tool output inside a single snapshot document.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories