Handling Side Effects in Mongoose: When to Use Document Middleware
Stop repeating data sanitization logic in your controllers. Learn how to use Mongoose pre and post hooks to centralize password hashing and side effects.
15 Apr 2026, 23:52 UTC

The Hidden Cost of Manual Data Sanitization
When building a Node.js service, you often need to perform repetitive tasks every time a record is saved—such as hashing a password, normalizing an email address to lowercase, or updating a "last modified" timestamp. Doing this manually in every controller or service function leads to "leaky logic," where a developer forgets to hash a password in one specific update route, creating a critical security vulnerability.
The solution is Mongoose document middleware. By attaching pre and post hooks directly to your Schema, you centralize these side effects. This ensures that regardless of where in your application .save() is called, the data is processed consistently before it hits MongoDB.
Pre-Save Hooks for Data Transformation
A pre hook runs before a specific operation. For document middleware, the save hook is the most common. It provides access to the document instance, allowing you to mutate fields directly. Because modern Mongoose versions support async/await, these hooks are ideal for CPU-intensive tasks like cryptography.
Crucially, document middleware only triggers when you call .save() or .create(). It does not trigger on updateMany() or findOneAndUpdate() because those operations happen directly at the database level without loading the document into memory first.
Post-Save Hooks for External Notifications
While pre hooks are for mutation, post hooks are for reactions. A post hook executes after the operation is complete. It receives the saved document as its first argument, making it the correct place to trigger side effects that shouldn't block the database write, such as sending a welcome email or clearing a Redis cache.
Worked Example: Conditional Password Hashing
In this example, we use a pre('save') hook to hash a password only if it has been modified. This prevents the password from being re-hashed (and thus corrupted) every time the user updates their profile picture.
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
password: { type: String, required: true }
});
// Pre-save hook for password hashing
userSchema.pre('save', async function(next) {
// 'this' refers to the document being saved
// Only hash the password if it was modified (or is new)
if (!this.isModified('password')) return next();
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (err) {
next(err); // Pass errors to the next middleware or the caller
}
});
const User = mongoose.model('User', userSchema);
Verifying the behavior
- Run
npm list mongoosein your project directory to confirm the installed major version. Middleware APIs have changed across major releases, so verify behavior against your version's documentation. - Create a new user using
await User.create({ email: '[contact removed]', password: 'plainpassword' }). - Query the document from the database and verify the
passwordfield is a bcrypt hash, not the plain text string. - Update the user's email and call
.save(); verify the password hash remains unchanged.
The Trade-off: Document vs. Query Middleware
The biggest pitfall in Mongoose middleware is the distinction between document and query middleware. If you use UserModel.updateOne({ _id: id }, { email: '[contact removed]' }), the pre('save') hook defined above will not run. This is because updateOne is a query-level operation that bypasses the document lifecycle.
| Feature | Document Middleware (save) | Query Middleware (update) |
|---|---|---|
| Context (this) | The Document instance | The Query object |
| Use Case | Hashing, Validation, Normalization | Updating timestamps, Audit logs |
| Performance | Slower (loads doc into memory) | Faster (direct DB operation) |
Limitations and Debugging Risks
While powerful, excessive use of hooks can make your code harder to debug. Since hooks run in the order they are registered, a long chain of pre hooks can create a "black box" where it is unclear why a field changed. To mitigate this, keep hooks focused on a single responsibility and avoid complex conditional logic inside the hook. If a side effect requires complex business logic, it is often better to handle it in a Service Layer rather than the Schema.
Additionally, unhandled exceptions thrown inside async pre hooks can reject the operation and may crash the process if not caught by the caller. Always ensure errors are passed to next(err) or thrown in a way the calling code handles.
Because middleware behavior varies between Mongoose major versions, treat this as a starting pattern and verify against your installed version before relying on it in production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.