DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Mongoose's populate() Will Happily Return Broken References, and Your UI Won't Warn You

If you've used Mongoose for more than a few projects, you've written something like this without thinking twice about it:

const order = await Order.findById(orderId).populate('customer');
console.log(order.customer.email);
Enter fullscreen mode Exit fullscreen mode

This works perfectly, every time, right up until the referenced customer document gets deleted. Then it doesn't throw an error. It doesn't log a warning. order.customer just quietly becomes null, and the very next line of code that assumes it's an object crashes in production, usually somewhere that has nothing obviously to do with the actual cause.

Why This Doesn't Show Up in Development

MongoDB has no built-in foreign key enforcement the way a relational database does. A ref field in a Mongoose schema is a convention Mongoose uses to know which collection to look in, not a constraint the database itself enforces. Nothing stops you from deleting a Customer document while fifty Order documents still reference its _id.

// models/Order.ts
const orderSchema = new Schema({
  customer: { type: Schema.Types.ObjectId, ref: 'Customer' },
  items: [{ type: Schema.Types.ObjectId, ref: 'Product' }],
  total: Number,
});
Enter fullscreen mode Exit fullscreen mode

In development, you're usually working with fresh, consistent seed data where every reference still points to something real. The dangling reference scenario only shows up once an app has run long enough in production for real deletions to happen, a customer closes their account, a product gets discontinued and removed, an admin cleans up test data, and by then the code that assumes populate() always returns a real object has already shipped and been running fine for months.

What populate() Actually Does With a Missing Reference

const order = await Order.findById(orderId).populate('customer');

if (order.customer) {
  // This block never runs for an order whose customer was deleted
  console.log(order.customer.email);
}

// This line runs unconditionally and throws
// "Cannot read properties of null (reading 'email')"
console.log(order.customer.email);
Enter fullscreen mode Exit fullscreen mode

When the referenced _id doesn't resolve to an existing document, populate() sets the field to null and moves on without complaint. For an array of references, it's slightly different and arguably worse, it silently drops the missing entries from the array entirely rather than leaving a null placeholder, which means order.items.length can quietly shrink below the number of items actually ordered, with no error anywhere to indicate that happened.

const order = await Order.findById(orderId).populate('items');

// If 3 of 5 referenced products were deleted,
// order.items now has length 2, not 5, with no indication
// that anything was ever missing
Enter fullscreen mode Exit fullscreen mode

Where This Actually Bites in Production

The crash itself is usually the easy part to fix once you find it. The harder problem is the silent array-shrinking case, because it doesn't crash at all, it just produces subtly wrong output. An order summary page showing 2 items instead of 5. A total calculated from the populated array that no longer matches the stored total field. A report aggregating "items per order" that's quietly undercounting across your entire historical dataset, for every order touching a deleted product, with nothing in your logs flagging that any of it happened.

The Fix: Guard at the Point of Use, and Check Array Length Deltas

// lib/orders.ts
export async function getOrderSafely(orderId: string) {
  const order = await Order.findById(orderId)
    .populate('customer')
    .populate('items');

  if (!order) return null;

  // Explicit guard instead of assuming the ref resolved
  const customerEmail = order.customer?.email ?? 'Unknown customer (deleted)';

  // Compare populated length against the raw reference count
  // to detect silently dropped array entries
  const rawItemCount = order.get('items', null, { getters: false })?.length ?? 0;
  if (order.items.length !== rawItemCount) {
    console.warn(
      `Order ${orderId} has ${rawItemCount - order.items.length} missing item reference(s)`
    );
  }

  return {
    ...order.toObject(),
    customerEmail,
  };
}
Enter fullscreen mode Exit fullscreen mode

The simpler and more durable fix for most apps, though, is deciding deliberately what "deletion" means for anything that's referenced elsewhere. A soft delete, flagging a document as deleted: true instead of removing it from the collection, keeps every existing reference intact and resolvable, while still letting you hide it from active queries everywhere else.

// Instead of Customer.findByIdAndDelete(id)
await Customer.findByIdAndUpdate(id, { deleted: true, deletedAt: new Date() });

// And filter deleted customers out of normal queries
const activeCustomers = await Customer.find({ deleted: { $ne: true } });
Enter fullscreen mode Exit fullscreen mode

Orders still populate a complete, real customer object. The data isn't gone, it's just excluded from the places where an active customer list matters, which is usually what "delete" actually meant in the first place for anything with historical records attached to it.

The Rule

Any field with a Mongoose ref is a soft link, not an enforced relationship, and populate() will never tell you when that link is broken. Either guard every place you read a populated field, or avoid hard-deleting anything that other documents reference by ID. The second option is less code to maintain long term, and it's the pattern I default to in the order and inventory logic behind my Next.js and MongoDB templates.

Get the templates: https://pixelanas.gumroad.com

Go check your own schemas for ref fields pointing at anything you ever actually delete. If you find one, this is very likely already happening somewhere in production. Drop what you find in the comments.


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)