# Mongoose Middleware: When to Use It, When to Avoid It
By Govind Bajaj · 2024

Tags: mongoose, mongodb, middleware, performance, backend
I disabled Mongoose middleware on two collections and cut API response times by 40%. Middleware is powerful but it's a footgun. Here's my ruleset.
I disabled Mongoose middleware on two collections last month and cut API response times by 40%.

The middleware wasn't doing anything obviously wrong. It was hashing passwords on save, updating timestamps, and logging changes. Standard stuff. But it ran on every `findOneAndUpdate`, every `save`, every `insertMany`. In a bulk import operation processing 10,000 documents, the middleware chain added 2.3 seconds of overhead.

Mongoose middleware is powerful. It's also a footgun. Here's the ruleset I use to decide when middleware is the right tool and when it needs to be ripped out.

## What Middleware Can Do

Mongoose supports four types of middleware, each with pre and post hooks:

- **Document middleware**: `init`, `validate`, `save`, `remove`
- **Query middleware**: `count`, `find`, `findOne`, `findOneAndDelete`, `findOneAndRemove`, `findOneAndUpdate`, `update`, `updateOne`, `updateMany`, `deleteOne`, `deleteMany`
- **Aggregate middleware**: `aggregate`
- **Model middleware**: `insertMany`

```js
// Document middleware: runs on save()
userSchema.pre('save', async function(next) {
  // 'this' is the document being saved
  if (this.isModified('password')) {
    this.password = await bcrypt.hash(this.password, 12)
  }
  next()
})

// Query middleware: runs on findOneAndUpdate()
productSchema.pre('findOneAndUpdate', function(next) {
  // 'this' is the Query object, not the document
  this.set({ updatedAt: new Date() })
  next()
})
```

The critical difference: in document middleware, `this` is the document. In query middleware, `this` is the Query object. This trips up developers constantly.

## When Middleware Is the Right Tool

**Password hashing**: This is the canonical use case. You never want to store plain-text passwords, and middleware ensures no code path can forget to hash.

```js
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next()
  this.password = await bcrypt.hash(this.password, 12)
  next()
})
```

**Timestamp management**: Automatic `createdAt` and `updatedAt` updates.

```js
schema.pre('save', function(next) {
  if (this.isNew) this.createdAt = new Date()
  this.updatedAt = new Date()
  next()
})
```

**Cross-field validation**: When one field's validity depends on another.

```js
orderSchema.pre('validate', function(next) {
  if (this.status === 'shipped' && !this.shipping.trackingNumber) {
    this.invalidate('shipping.trackingNumber', 'Tracking number required for shipped orders')
  }
  next()
})
```

## When Middleware Becomes a Problem

**Bulk operations**: Middleware runs per document. A bulk insert of 10,000 products triggers the `save` middleware 10,000 times. If that middleware hashes a password or calls an external API, you've created a performance bottleneck.

**Hidden side effects**: Middleware runs implicitly. A developer calling `Product.findOneAndUpdate()` might not know that a pre-hook modifies the query or a post-hook sends an email. This creates spooky action at a distance.

**Error debugging**: When middleware throws, the stack trace points to the middleware, not the calling code. Debugging a `save()` failure that actually stems from a pre-save hook is unnecessarily painful.

**Testing complexity**: To test a model method, you need to account for all middleware it triggers. Unit testing becomes integration testing.

## My Current Ruleset

After the performance incident, I established these rules for the Sans Herbals codebase:

1. **Middleware only for data integrity** — password hashing, timestamps, field normalization. Things that must happen for the database to remain consistent.

2. **No middleware for business logic** — sending emails, updating search indexes, calling external APIs. These go in service layer functions where they're explicit and testable.

3. **No middleware for logging** — use database triggers or application-level logging instead. Middleware logging is invisible and easy to forget about.

4. **Skip middleware for bulk operations** — use `Model.insertMany(docs, { ordered: false })` or raw MongoDB driver calls when importing data.

```js
// Skip middleware on bulk imports
await Product.insertMany(products, {
  ordered: false,  // continue on error
  // Mongoose skips document middleware for insertMany by default
})

// Or use the raw driver for maximum speed
await Product.collection.insertMany(products)
```

5. **Document every middleware** — every pre/post hook gets a comment explaining what it does and why it exists.

## The Alternative: Service Layer Pattern

Instead of middleware, I use explicit service functions that combine data operations with side effects:

```js
// services/productService.js
async function createProduct(data, userId) {
  // Data operation
  const product = await Product.create(data)
  
  // Side effects — explicit and testable
  await searchIndex.add(product)
  await auditLog.record('PRODUCT_CREATED', { productId: product._id, userId })
  
  return product
}

async function updateProduct(id, updates, userId) {
  const product = await Product.findByIdAndUpdate(id, updates, { new: true })
  
  if (!product) throw new NotFoundError('Product')
  
  await searchIndex.update(product)
  await auditLog.record('PRODUCT_UPDATED', { productId: id, userId, changes: updates })
  
  return product
}
```

Every side effect is visible in the function body. No hidden hooks. Stack traces point to the right place. Tests can mock individual operations.

## Takeaways

- Middleware is for data integrity (hashing, timestamps), not business logic
- Document middleware runs per document — bulk operations amplify overhead dramatically
- Hidden side effects in middleware create debugging and testing nightmares
- The service layer pattern makes side effects explicit, testable, and debuggable
- Use raw driver methods (`collection.insertMany`) for bulk imports to skip middleware entirely
- If you can't find the middleware that's causing a problem, check all four types: document, query, aggregate, and model