How do you design a MongoDB schema for high-write apps — embed vs reference — and what breaks when an array you assumed was bounded turns out not to be?
Quick Answer
Embed data that's always read together with its parent and has a genuinely enforced cap on growth; reference data that grows unboundedly or needs independent access patterns, since every MongoDB document has a hard 16MB size ceiling and unbounded embedded arrays degrade every write on that document long before they hit it. The real failure mode isn't picking embed vs reference wrong on day one — it's an array assumed bounded (order line items) quietly becoming unbounded (a decade of subscription events) as the product evolves.
Detailed Answer
Think of embedding like stapling a customer's entire receipt history into one folder, versus referencing like keeping a customer file that just lists ticket numbers pointing to receipts stored in a separate filing cabinet. A stapled folder is fast to grab because everything is in one place. But if that customer generates thousands of receipts — a subscription billing daily for ten years — the folder becomes unmanageably thick, slows down every clerk who lifts it, and eventually can't fit in the drawer at all.
MongoDB stores related data as a single BSON document instead of joining across tables at query time like a relational database, and it enforces a hard 16MB per-document size limit (BSON is the binary JSON-like format MongoDB stores documents in) specifically to keep documents fitting comfortably in memory and network payloads. Embedding was designed for the common case where an application always reads a parent with its children in one request — an order and its line items — because a single document read is one disk seek and one round trip, versus a reference model's multiple round trips or a $lookup join. Referencing exists for the opposite case: data accessed independently of its parent, or that would make the parent balloon without bound.
When you embed, MongoDB stores the child data as a nested array inside the parent's BSON document. Every write to that document — even one touching a single embedded item — requires the storage engine to read and rewrite the relevant parts of the whole document, so a document with a 50,000-element array pays a growing tax on every write, not just reads. Referencing instead stores just an ObjectId (MongoDB's unique document identifier) pointing to another collection, and the application or an aggregation $lookup stage does a second query to join them; this keeps each document small and its write cost constant regardless of how many children exist, at the cost of an extra index and round trip.
In production, teams use the bucket pattern for genuinely high-write, time-growing data like IoT readings or activity events: instead of one document per device with an ever-growing array, they create a new document every N items or every time window (one document per device per hour), each with a bounded embedded array. This caps document size while keeping writes append-only. Engineers monitor average and p99 document size via db.collection.stats(), watch for documents approaching the 16MB ceiling well before they hit it, and alert on write latency creeping up on documents with large embedded arrays, since that's usually the earliest symptom of a design that's aging badly.
The gotcha that bites teams who designed correctly on day one: 'bounded' arrays are often only bounded by current product usage, not by anything structural. An order.line_items array is fine at 5-20 items until a bulk-ordering feature ships and some orders have 3,000 items, or a user.notifications array grows forever because nobody added a cleanup job. The array being unbounded doesn't throw an error until the exact moment a document crosses 16MB, so the failure is invisible in code review and in most of production, then appears suddenly as a hard write rejection on exactly the customer with the most data — usually your biggest account.