The MongoDB Schema That Worked Until the Documents Kept Growing
A case, a doc array, and a schema that made perfect sense until the documents kept coming. A lesson I picked up at my organization about embedding data in MongoDB.
- Author
- Faisal Husain
- Published
- Reading time
- 9 min read
- Share

When I first joined my organization, I came across a MongoDB schema that looked perfectly reasonable to me. We had cases, and each case had documents attached to it. Those document records lived in a doc array inside the case object.
A case has documents. Put the documents inside the case. Easy enough.
With 40–50 documents in a case, there was not much to question. Fetch the case, get its documents, show what you need. Looking at that, I could see why it had been built this way.
But the documents kept coming.
And gradually, fetching the case started taking longer. The request still looked innocent: get me this case. But “this case” now included an ever-growing list of documents. Even when we only needed the case details, the whole array came along for the ride.
Giving those document records their own collection let us fetch them separately, when we actually needed them. Simple enough when I put it like that. But what stayed with me was that the original design had never looked strange. At 40–50 documents, I would not have looked at that array and thought, “This is going to make fetching the case slower.”
This post is what I learned from that. The schemas below are simplified examples of the same shape, not our production fields or measurements.
Why putting everything together made sense
Here is the shape we started with:
// cases (simplified)
{
_id: ObjectId("..."),
name: "Case A",
status: "open",
doc: [
{ _id: ObjectId("..."), name: "application.pdf", createdAt: ISODate("2026-06-01T08:00:00Z") },
{ _id: ObjectId("..."), name: "supporting-document.pdf", createdAt: ISODate("2026-06-02T09:00:00Z") },
],
}Here, a document record means an object describing an attached file. It does not mean the PDF bytes are sitting inside this array. MongoDB also calls the whole case a document, which makes explaining this slightly annoying.
Adding another record looks straightforward:
db.cases.updateOne({ _id: caseId }, { $push: { doc: documentRecord } })One case, one place to update, one read to get everything. MongoDB also gives you atomic updates within that single document. There are real reasons to like this design.
The problem was the assumption hiding inside it: that the list would stay small enough, and that we would always want the whole thing with the case.
We had a relationship. We had not given that relationship a limit.
Same data, two shapes
Where do the document records live? Inside the case, or in their own collection?
One read returns the case and every document record. That is the appeal, and the problem: the array keeps growing with every attachment.
The case stayed the same. The response kept growing.
Think about opening a case just to see its name and status.
db.cases.findOne({ _id: caseId })That query returns doc too. Whether the screen uses it or not.
At 40–50 entries, that extra data did not seem like much. Keep adding entries, and the same request has more data to transfer, deserialize, and hold in application memory. The user is still opening one case. The amount of work behind that action keeps changing.
This is the bit I had to learn: finding one MongoDB document does not mean fetching a small amount of data.
An index on _id can find the case quickly. It cannot make the array inside the case smaller. In my post on explain plans, the question was how much MongoDB scans to find a result. Here, the result itself was getting heavier.
A projection is a useful immediate improvement:
db.cases.findOne({ _id: caseId }, { doc: 0 })Now the array is left out of the response. That saves network traffic and application work on the case details path. It is worth doing when you do not need those fields.
But it does not remove the array from the stored case. An ordinary fetch still works with that large database document internally; a covered query can avoid fetching it if the index contains everything the query needs. A smaller response and a smaller stored document solve different parts of the problem.
There is a ceiling, but I would not wait for it
MongoDB has a 16 MiB BSON document limit: 16,777,216 bytes for the entire case, including its array. If an update would push it over that limit, the write fails. That is the documented size limit.
There is no universal number of attachments that fits. A record with a name and timestamp is smaller than one with lots of metadata. Field names and array overhead take space too.
The playground below uses made-up record sizes so you can see the growth. These are illustrations, not measurements from our system.
Grow one case's document list
Drag the slider and watch the embedded case document approach MongoDB's 16 MB limit.
Totally fine. A small document, one read, everything you need. At this average size it would break at record #33,024.
Illustrative BSON sizes: a 1.2 KB case plus the selected record size and approximate array overhead. This is not a latency or disk I/O benchmark; real sizes vary with fields and content.
The limit is worth knowing. It is also the wrong point to wait for before changing the design.
Our issue was already visible in fetching. We did not need a rejected write to tell us that loading a growing document list with every case request was unnecessary work.
Can we just paginate the array?
You can return a slice of it:
db.cases.findOne(
{ _id: caseId },
{ doc: { $slice: [100, 20] } }
)That returns 20 entries rather than the full array. Useful for the response size, but the entries still live inside the same growing case document. $slice is not an independent indexed query over a collection of document records.
And reads are only one side of it. Every upload that appends a record updates the same case. So does an update to one of those embedded records. Concurrent writes to the same MongoDB document can conflict and require retries.
That does not mean a few uploads will immediately cause a bottleneck. It means the schema makes otherwise separate records share a write target.
Six uploads add records to the same case
Compare a shared parent update with inserts into separate document records.
$push into cases.doc
one document, one writer at a time · 0/6 saved · 5.4sinsertOne into caseDocuments
independent documents · 0/6 saved · 0.6sIllustrative timings, not a benchmark. Concurrent updates to the same MongoDB document can conflict and require retries. Separate inserts avoid that shared parent write target; actual throughput still depends on indexes, hardware, and workload.
Give the documents their own collection
The change is small to draw:
// cases
{
_id: ObjectId("..."),
name: "Case A",
status: "open",
}
// caseDocuments (one record per attached document)
{
_id: ObjectId("..."),
caseId: ObjectId("..."),
name: "application.pdf",
createdAt: ISODate("2026-06-01T08:00:00Z"),
}The document still belongs to the case. caseId expresses that relationship. We have changed where it lives and when we fetch it.
Opening the case fetches the case:
db.cases.findOne({ _id: caseId })Opening its documents fetches a page of document records:
db.caseDocuments
.find({ caseId })
.sort({ createdAt: -1, _id: -1 })
.limit(20)Now a case can collect more documents without making its own record grow with every attachment. And a screen that does not need the documents does not query them.
That was the useful change for me. We stopped making every case fetch pay for a list it might not use.
The separate collection needs an index too
Moving the array out does not automatically make the new query fast. Without a suitable index, we could trade one expensive read for another.
For the query above:
db.caseDocuments.createIndex({ caseId: 1, createdAt: -1, _id: -1 })caseId narrows the query to one case. createdAt orders its records newest first. _id breaks ties when timestamps match, so pagination has a stable order.
For the next page, use the last record from the previous page as a cursor:
db.caseDocuments
.find({
caseId,
$or: [
{ createdAt: { $lt: last.createdAt } },
{ createdAt: last.createdAt, _id: { $lt: last._id } },
],
})
.sort({ createdAt: -1, _id: -1 })
.limit(20)This avoids walking past an ever-growing offset with skip(). Check the actual plan with explain("executionStats"); the index is there to support the filter and sort, not to promise identical timings for every dataset.
Adding a document record is now an insert:
db.caseDocuments.insertOne({
caseId,
name: "another-document.pdf",
createdAt: new Date(),
})It does not need to append anything to the parent case.
Yes, sometimes that means two queries
If a screen needs both the case and its documents, the separate design means fetching both. You also have another collection and an index to maintain.
Those costs are real. So is the cost of fetching every document on screens that do not need them.
Keep the schema. Grow the case.
Illustrative small records: compare 40 entries with a history that outgrows one case.
- An append fails if the resulting case exceeds 16 MiB. The count depends on record sizes.
- A full case fetch returns the growing array, even on screens that do not need it.
- Every append targets the same case document; concurrent writes can conflict.
- $slice reduces the response, but does not stop the stored case from growing.
I would not turn this into “always use references.” If the array has a real bound, stays small, and is usually read with the parent, embedding can be the simpler choice.
But “there are only 40 right now” is not a bound. That is just how much data you have today.
There is another tradeoff worth keeping: a single-document update was atomic before. If an operation now needs to change both a case and a document record together, you need to decide how to preserve consistency. The same goes for deleting a case and cleaning up its document records. A reference does not do that cleanup for you.
What if the case needs a small preview?
Sometimes the case screen really does need a few recent documents. Fetching the entire history for that is still unnecessary.
One option is the subset pattern: keep the full list in caseDocuments, and a bounded preview on the case.
// cases (optional preview)
{
_id: ObjectId("..."),
name: "Case A",
status: "open",
recentDocuments: [ /* at most 3 small previews */ ],
}The important word is bounded. Three small previews, with a rule that keeps it at three. Not a second copy of the list that quietly grows again.
I would only add that if the screen needed it. Now there is duplicated data to keep in sync when a record changes or gets deleted. A separate indexed query is often enough.
What I ask now
Before putting a list inside a MongoDB document, I ask:
- What stops this list from growing? A product rule is an answer. The current count is not.
- Do we actually fetch these together? Belonging to the same case does not mean every screen needs both.
- Do these records change independently? If they do, should they all update the same parent?
- What happens to a case that keeps accumulating data? The schema needs to handle that case too.
MongoDB calls the growing-list problem the unbounded arrays anti-pattern. Having a name for it helps. Seeing it in a case fetch made it stick.
At 40–50 documents, the original design was easy to understand. The lesson was to look beyond that snapshot: what keeps growing, and which requests have to carry it?
A case has documents. They can still belong together without being fetched together every time.
-- bye for now
Let's work together
Have a product problem worth solving?
I help teams build fast, dependable products with thoughtful interfaces and strong engineering foundations.