Mongoose Middleware: When Pre/Post Hooks Are the Right Place for Cross-Cutting Logic
Mongoose pre/post hooks are ideal for password hashing, audit logs, and slug generation — but they silently skip insertMany and bulkWrite. A worked bcrypt example plus the limits and mistakes that matter.
31 Aug 2025, 20:01 UTC

The useful answer first
Mongoose middleware (pre/post hooks) lets you run functions automatically before or after model operations like save, findOneAndUpdate, or remove. It is the right tool for cross-cutting concerns — password hashing, audit logging, slug generation, timestamp normalization — that would otherwise be copy-pasted into every route handler. It is the wrong tool for multi-step business workflows, and it silently does not run at all on raw collection operations like insertMany() or bulkWrite().
Everything below assumes Mongoose 6.x or newer on Node.js 14+, where hooks support async/await and you no longer need to call next() manually in async functions.
How the mechanism works
When you register a hook on a schema, Mongoose wraps the matching operation with your function. A document hook (like save) runs with this bound to the document being saved. A query hook (like find or findOneAndUpdate) runs with this bound to the query object, so the document itself is only available in the post hook as the result argument.
That distinction drives most design decisions: if your logic needs to mutate the record before it hits the database, use a document pre hook. If it needs to react to the result, use a query post hook.
Worked example: password hashing plus an audit log
This is the canonical supported use case. Run this in any Node.js script or module where you define your models; no special permissions needed beyond your normal MongoDB connection.
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
passwordHash: { type: String, required: true }
});
// Document hook: hash the password only when it was modified.
userSchema.pre('save', async function () {
if (!this.isModified('passwordHash')) return;
this.passwordHash = await bcrypt.hash(this.passwordHash, 12);
});
// Query hook: log after an update completes.
userSchema.post('findOneAndUpdate', function (doc) {
if (doc) {
console.log(`[audit] user ${doc._id} updated at ${new Date().toISOString()}`);
}
});
const User = mongoose.model('User', userSchema);Two details matter here. First, this.isModified('passwordHash') prevents re-hashing an already-hashed password every time the user changes their email — a classic bug that locks users out. Second, the async pre hook has no next() parameter; Mongoose waits for the returned promise. If you add a next parameter to an async function and never call it, the save hangs forever.
Verifying the hook actually ran
Do not trust the hook because the code looks right. Write a small check:
await mongoose.connect(process.env.MONGO_URI);
const u = await User.create({ email: 'a@b.c', passwordHash: 'secret123' });
console.log(u.passwordHash.startsWith('$2')); // true => bcrypt hash, not plaintext
console.log(await bcrypt.compare('secret123', u.passwordHash)); // trueIf the first log prints false, the hook did not run — usually because the model was compiled before the hook was registered, or because you used a bypassing method (see below). For the audit hook, run a findOneAndUpdate and confirm the log line appears exactly once.
Where middleware silently does not run
This is the limit that bites teams in production:
Model.insertMany()skipssavehooks by default (pass{ rawResult: false }— no; the actual option is that insertMany does not fire document middleware unless you avoid it and loopsave()calls instead).Model.collection.insertMany()andbulkWrite()bypass Mongoose entirely — they talk to the driver directly. No validation, no hooks, no casting guarantees.updateOne/updateManyfire query middleware, not document middleware, sothisis the query andisModified()is unavailable. Apre('save')password hasher will never run onupdateOne.
If a write path must trigger hooks, route it through document.save() or explicitly register a query hook for that operation and fetch the document inside it.
Common mistakes and how to avoid them
- Mixing
next()andasync: pick one. Withasynchooks, throw to abort the operation; never callnext(). - Putting business workflows in hooks: a
pre('save')that sends emails, charges cards, and updates three other collections becomes undebuggable. Keep hooks to single, idempotent responsibilities; orchestrate workflows in a service layer. - Re-hashing or re-transforming on every save: always guard mutations with
isModified()or an equivalent check. - Performance: hooks run per document. A
post('find')hook that transforms results executes once per returned document — fine for 20 records, painful for 20,000. Measure before adding per-document logic to hot query paths.
Decision summary
Use middleware when the logic is (a) required on every code path that performs the operation, (b) scoped to a single document or query, and (c) cheap. Reach for explicit service-layer code when the logic involves multiple models, external calls, or conditional flows — and always audit your codebase for bulkWrite and collection.* calls that bypass your hooks entirely.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.