Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 063 / 100

Common MongoDB anti-patterns: what developers should know

Mistakes that cause slow apps.

Expona Admin7 min read

After two months of building with MongoDB, you've learned the basics—but your application is starting to slow down in production. What went wrong? In this post, we'll explore common MongoDB anti-patterns: what developers should know to avoid the most expensive mistakes we see in real-world deployments across e-commerce, SaaS, and trading platforms.

These pitfalls aren't theoretical. We've debugged them in systems handling millions of documents, and fixing them after the fact is far more costly than designing around them from the start. Let's walk through the patterns that cause slow apps and how to recognize them early.

Unbounded arrays — Documents that grow forever

One of MongoDB's most seductive features is the ability to store arrays inside documents. It feels natural and denormalizes data beautifully. But when an array has no size limit, it becomes a ticking time bomb.

Imagine a user document that stores every order they've ever placed:

javascript

// ❌ Anti-pattern: unbounded array
{
  _id: ObjectId("..."),
  email: "customer@example.com",
  created_at: ISODate("2023-01-15"),
  orders: [
    { id: 1, total: 49.99, date: ISODate("2023-01-20") },
    { id: 2, total: 129.50, date: ISODate("2023-02-10") },
    // ... 10,000 more orders ...
    { id: 10000, total: 89.99, date: ISODate("2024-12-10") }
  ]
}

On day one, this looks efficient. By month twelve, a loyal customer's document is megabytes in size. Every read—even just to verify their email—pulls the entire order history into memory. Updates become painfully slow because MongoDB must rewrite the entire document to disk.

The MongoDB team itself recommends a hard limit: keep arrays under a few hundred items. For growing collections, use a separate collection and link by reference instead:

javascript

// ✅ Better: separate collections with references
db.users.insertOne({
  _id: ObjectId("..."),
  email: "customer@example.com",
  created_at: ISODate("2023-01-15")
});

db.orders.insertMany([
  { _id: ObjectId("..."), user_id: ObjectId("..."), total: 49.99, date: ISODate("2023-01-20") },
  { _id: ObjectId("..."), user_id: ObjectId("..."), total: 129.50, date: ISODate("2023-02-10") }
]);

// Fetch user with order count
db.users.aggregate([
  { $match: { _id: ObjectId("...") } },
  { $lookup: { from: "orders", localField: "_id", foreignField: "user_id", as: "orders" } },
  { $addFields: { order_count: { $size: "$orders" } } }
]);

This pattern scales linearly. You can index orders by user_id and query just what you need. You've traded a single read penalty for architectural flexibility.

Too many collections — One per user or per day

Developers sometimes fight MongoDB's schema flexibility by creating dozens or hundreds of collections. We've seen systems with one collection per user (in a 100k-user SaaS), or one collection per day (in high-volume trading platforms). This leads to operational chaos.

Consider a trading platform that creates a new collection for each trading day:

javascript

// ❌ Anti-pattern: proliferating collections
db.trades_2024_01_15.insertOne({ symbol: "AAPL", price: 150.25, volume: 1000 });
db.trades_2024_01_16.insertOne({ symbol: "AAPL", price: 151.00, volume: 1500 });
db.trades_2024_01_17.insertOne({ symbol: "AAPL", price: 149.80, volume: 900 });
// ... hundreds of collections by end of year ...

Problems emerge quickly:

  • Querying across time requires querying multiple collections—no single index helps you find all AAPL trades from the last month.
  • Index overhead multiplies; each collection maintains its own index metadata and statistics.
  • Backup and restore complexity explodes; tools expect a reasonable number of collections.
  • Monitoring becomes manual; you can't easily track which collections are hot or which are dormant.

Use a single collection with a timestamp field and partition by date in your application logic:

javascript

// ✅ Better: single collection with time-based queries
db.trades.insertOne({
  _id: ObjectId("..."),
  symbol: "AAPL",
  price: 150.25,
  volume: 1000,
  timestamp: ISODate("2024-01-15T09:30:00Z")
});

// Index on symbol and timestamp
db.trades.createIndex({ symbol: 1, timestamp: -1 });

// Query trades from last month
db.trades.find({
  symbol: "AAPL",
  timestamp: { $gte: ISODate("2024-01-15"), $lt: ISODate("2024-02-15") }
}).limit(1000);

MongoDB 5.1+ supports time-series collections for this exact pattern, with built-in compression and optimizations:

javascript

// ✅ Even better: native time-series collection
db.createCollection("trades", {
  timeseries: {
    timeField: "timestamp",
    metaField: "metadata",
    granularity: "minutes"
  }
});

Missing indexes — Every frequent query needs one

MongoDB's performance profile changes drastically once you stop fitting data in memory. A query without an index becomes a collection scan—examining every document. At scale, this is a silent killer.

Suppose you're building a CRM and frequently fetch contacts by company and status:

javascript

// ❌ Anti-pattern: no index
db.contacts.find({ company_id: ObjectId("..."), status: "active" });

With 1 million contacts, this scan takes 500ms. Do this 1,000 times per minute and your database is bottlenecked.

The fix is straightforward—index the fields you query on:

javascript

// ✅ Add a compound index
db.contacts.createIndex({ company_id: 1, status: 1 });

// Same query now returns in milliseconds
db.contacts.find({ company_id: ObjectId("..."), status: "active" });

Use MongoDB's explain() to detect these silent killers:

javascript

db.contacts.find({ company_id: ObjectId("..."), status: "active" }).explain("executionStats");
// If "executionStages.stage" is "COLLSCAN", you're scanning every document.
// If it's "IXSCAN", you're using an index. Target this.

Establish a rule: every query in your application should have a corresponding index, or be intentionally small enough to scan. Track slow queries in production; MongoDB logs queries exceeding your threshold.

Relational modeling — Normalizing everything leads to constant lookups

Some teams over-correct for MongoDB's flexibility and normalize data aggressively—mimicking SQL schemas. This defeats MongoDB's strength and creates a join-heavy nightmare.

Consider an e-commerce order system:

javascript

// ❌ Anti-pattern: over-normalized
db.orders.insertOne({
  _id: ObjectId("..."),
  user_id: ObjectId("..."),
  product_ids: [ObjectId("..."), ObjectId("...")],
  created_at: ISODate("2024-01-15")
});

// To display an order, you need three lookups
const order = db.orders.findOne({ _id: ObjectId("...") });
const user = db.users.findOne({ _id: order.user_id });
const products = db.products.find({ _id: { $in: order.product_ids } }).toArray();

Three database round-trips for a single business object. Scale this to displaying 50 orders on a page and you've made 150 queries.

Denormalize strategically. Store data that doesn't change frequently (or rarely) directly in the document:

javascript

// ✅ Better: strategic denormalization
db.orders.insertOne({
  _id: ObjectId("..."),
  user: {
    _id: ObjectId("..."),
    email: "customer@example.com",
    name: "Alice"
  },
  products: [
    {
      _id: ObjectId("..."),
      sku: "WIDGET-001",
      name: "Blue Widget",
      price: 49.99,
      quantity: 2
    },
    {
      _id: ObjectId("..."),
      sku: "GADGET-002",
      name: "Red Gadget",
      price: 79.99,
      quantity: 1
    }
  ],
  total: 179.97,
  created_at: ISODate("2024-01-15")
});

// One query, everything you need
const order = db.orders.findOne({ _id: ObjectId("...") });

If product prices or user emails change, decide which data needs consistency. For orders, historical data matters more—snapshot product prices at purchase time. For user profiles, refresh the denormalized fields occasionally or on update. This is a design choice, not a bug.

Common mistakes

  • Assuming arrays are free: Arrays with no growth limit destroy document size and query performance. Always set a maximum or use a separate collection.
  • Chasing collections: Creating collections for time, user, or tenant boundaries sounds clever but fractures your queries and complicates operations. Consolidate and index instead.
  • Running blind without explain(): If you don't know why a query is slow, you're guessing. Use explain() and slow query logs from day one.
  • Treating MongoDB like SQL: MongoDB shines when you denormalize thoughtfully. Excessive normalization forces you back into join hell.

Key takeaways

  • Unbounded arrays will bloat your documents and destroy query performance. Use a separate collection if arrays grow beyond a few hundred items.
  • Too many collections (per user, per day, per tenant) fragment your data and make cross-collection queries expensive. Consolidate and use indexes strategically.
  • Every frequent query pattern should have a corresponding index. Use explain() to detect collection scans and fix them before they hit production.
  • Denormalization is MongoDB's superpower, not its weakness. Store data that's read together in the same document, but be intentional about consistency boundaries.
  • Monitor real production queries, not lab benchmarks. Slow query logs and explain() output reveal the anti-patterns before they become outages.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles