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

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.