Database

MongoDB in Production: Hard Lessons from Building ERP Systems

February 12, 2024
13 min read
By Saad Minhas
MongoDBDatabaseBackendPerformanceBest Practices
MongoDB in Production: Hard Lessons from Building ERP Systems

I've used MongoDB in production for multiple projects—ERP systems, e-commerce platforms, and various web applications. Along the way, I made every mistake possible. Here's what I learned so you don't have to.


The Database That Worked... Until It Didn't


At one of my projects, we built an ERP system with MongoDB. For the first few months, everything was smooth. Queries were fast, development was easy, and we loved the flexibility.


Then we hit 100,000 documents. Queries slowed down. Then 500,000. The dashboard took 15 seconds to load. Users were furious.


The problem wasn't MongoDB—it was how I was using it.


Schema Design: Flexibility Is a Trap


MongoDB's flexibility is both its strength and its weakness. You can store anything without thinking about schema. So I didn't think about schema.


My mistake: I embedded everything.


// What I did (wrong)
{
  _id: ObjectId("..."),
  orderNumber: "ORD-001",
  customer: {
    name: "John Doe",
    email: "john@example.com",
    address: {...},
    orderHistory: [{...}, {...}, {...}] // Hundreds of orders
  },
  items: [{
    product: { // Full product document
      name: "Widget",
      description: "A really long description...",
      specifications: {...},
      reviews: [{...}] // All reviews embedded
    },
    quantity: 2
  }]
}

This document grew to 500KB. Every query fetched 500KB of data even when I only needed the order number.


What I should have done:


// Orders collection
{
  _id: ObjectId("..."),
  orderNumber: "ORD-001",
  customerId: ObjectId("..."), // Reference
  items: [{
    productId: ObjectId("..."), // Reference
    quantity: 2,
    priceAtPurchase: 29.99 // Store what you need
  }]
}

// Customers collection (separate)
{
  _id: ObjectId("..."),
  name: "John Doe",
  email: "john@example.com"
}

// Products collection (separate)
{
  _id: ObjectId("..."),
  name: "Widget",
  price: 29.99
}

The rule: Embed data that's always accessed together and doesn't change often. Reference data that grows unboundedly or is accessed independently.


Indexing: The Performance Multiplier


I didn't understand indexes when I started. I thought MongoDB would handle it automatically.


Reality check: A query without an index scans every document. With 500,000 documents, that's slow.


// This query was taking 3 seconds
const orders = await Order.find({
  status: 'pending',
  createdAt: { $gte: lastWeek }
}).sort({ createdAt: -1 });

The query was scanning all 500,000 orders every time. Adding a compound index fixed it:


// Create compound index
db.orders.createIndex({ status: 1, createdAt: -1 });

// Same query now takes 50ms

Index strategies I use:


1. Index fields you query frequently

// If you query by email often
db.users.createIndex({ email: 1 }, { unique: true });

2. Compound indexes for multi-field queries

// If you filter by status AND sort by date
db.orders.createIndex({ status: 1, createdAt: -1 });

3. Use explain() to understand query performance

db.orders.find({ status: 'pending' }).explain('executionStats');
// Shows if index was used and how many documents scanned

The N+1 Query Problem


This one hurt. I was loading a list of orders with customer details:


// The N+1 problem
const orders = await Order.find({ status: 'pending' });

for (const order of orders) {
  order.customer = await Customer.findById(order.customerId);
  order.items = await Promise.all(
    order.itemIds.map(id => Product.findById(id))
  );
}
// With 50 orders and 3 items each: 1 + 50 + 150 = 201 queries!

The fix: Aggregation pipeline


const orders = await Order.aggregate([
  { $match: { status: 'pending' } },
  {
    $lookup: {
      from: 'customers',
      localField: 'customerId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' },
  {
    $lookup: {
      from: 'products',
      localField: 'items.productId',
      foreignField: '_id',
      as: 'productDetails'
    }
  }
]);
// 1 query instead of 201

Query time dropped from 8 seconds to 200ms.


Pagination: Don't Use Skip


For pagination, I initially used skip:


// Don't do this
const page1 = await Order.find().skip(0).limit(20);
const page2 = await Order.find().skip(20).limit(20);
const page100 = await Order.find().skip(1980).limit(20); // Slow!

Skip scans all documents up to the skip count. Page 100 means scanning 1980 documents to skip them.


Better approach: Cursor-based pagination


// First page
const firstPage = await Order.find()
  .sort({ _id: -1 })
  .limit(20);

// Next pages use the last ID
const lastId = firstPage[firstPage.length - 1]._id;
const nextPage = await Order.find({ _id: { $lt: lastId } })
  .sort({ _id: -1 })
  .limit(20);

This uses the index and is fast regardless of page number.


Transactions: When You Need Them


MongoDB has transactions, but they're not free. They add overhead and complexity.


When I use transactions:


// Money transfers, order processing - things that must be atomic
const session = await mongoose.startSession();
session.startTransaction();

try {
  await Account.updateOne(
    { _id: fromAccount },
    { $inc: { balance: -amount } },
    { session }
  );
  
  await Account.updateOne(
    { _id: toAccount },
    { $inc: { balance: amount } },
    { session }
  );
  
  await session.commitTransaction();
} catch (error) {
  await session.abortTransaction();
  throw error;
} finally {
  session.endSession();
}

When I don't use transactions:

  • Single document updates (they're atomic by default)
  • Operations where eventual consistency is acceptable
  • Read-only operations

Connection Management: Pool Wisely


I didn't understand connection pooling and created new connections for every request:


// Wrong - new connection every request
app.get('/orders', async (req, res) => {
  const connection = await mongoose.connect(MONGO_URI);
  const orders = await Order.find();
  await connection.close();
  res.json(orders);
});

This was slow and hit MongoDB's connection limit.


Correct approach:


// Connect once, reuse connection
mongoose.connect(MONGO_URI, {
  maxPoolSize: 10, // Adjust based on your load
  serverSelectionTimeoutMS: 5000,
  socketTimeoutMS: 45000,
});

// Reuse in routes
app.get('/orders', async (req, res) => {
  const orders = await Order.find();
  res.json(orders);
});

Data Modeling Patterns That Work


1. The Bucket Pattern (for time-series data)


Instead of one document per data point:


// Group data into buckets
{
  sensorId: "sensor-001",
  date: ISODate("2024-01-15"),
  readings: [
    { time: "09:00", value: 23.5 },
    { time: "09:01", value: 23.6 },
    // ... 1440 readings for the day
  ],
  summary: {
    min: 22.1,
    max: 25.8,
    avg: 23.7
  }
}

This reduces document count and makes date-range queries faster.


2. The Computed Pattern (for expensive calculations)


Pre-compute expensive values:


{
  _id: ObjectId("..."),
  productId: ObjectId("..."),
  ratings: [5, 4, 5, 3, 5, 4],
  // Pre-computed
  ratingCount: 6,
  ratingSum: 26,
  averageRating: 4.33
}

Update these when ratings change, not on every query.


Monitoring: Know Your Database


I set up basic monitoring that saved me multiple times:


// Log slow queries
mongoose.set('debug', (collectionName, methodName, ...methodArgs) => {
  const start = Date.now();
  // ... after query
  const duration = Date.now() - start;
  if (duration > 100) {
    console.warn(`Slow query: ${collectionName}.${methodName} took ${duration}ms`);
  }
});

Also monitor:

  • Connection pool usage
  • Document sizes
  • Index usage
  • Query patterns

What I'd Do Differently


1. Plan schema before coding. Think about access patterns upfront.

2. Add indexes from day one. Don't wait for performance problems.

3. Use aggregation pipelines. They're powerful and efficient.

4. Monitor everything. Know when things are slowing down.

5. Test with realistic data. 100 documents doesn't show real performance.


The Bottom Line


MongoDB is great, but it's not magic. You still need to:

  • Design schemas thoughtfully
  • Create proper indexes
  • Write efficient queries
  • Monitor performance

The flexibility of MongoDB is powerful, but it requires discipline. Without it, you'll end up with a slow, unscalable mess like I did.


Learn from my mistakes. Your future self will thank you.

Get In Touch

Connect

Full Stack Software Engineer passionate about building innovative web and mobile applications.

© 2025 Saad Minhas. All Rights Reserved.