The Mongoose Bug That Taught Me to Stop Trusting findByIdAndUpdate
I want to talk about a bug that cost me a few hours, because I think the
reason it happened is more useful than the fix itself. It's the kind of bug
that doesn't throw an error, doesn't crash anything, and just quietly does
the wrong thing — which is the worst kind.
The setup
I was working on the Result module for KDC, a school management system I'm
building. Part of the flow involves updating a student's result document —
recalculating a final score, and by extension, a grade — whenever a score
field changes. I had a Mongoose pre('save') hook on the Result model to
handle exactly that:
resultSchema.pre('save', function (next) {
this.finalScore = (this.cbtScore + this.writtenScore) / 2;
this.grade = calculateGrade(this.finalScore);
next();
});
Straightforward. Whenever a result document gets saved, recompute the score
and grade from whatever the current values are. I tested it against a
document I created and saved directly, and it worked exactly as expected.
Then I wired up the actual update endpoint using the pattern I'd used
everywhere else in the codebase:
const updated = await Result.findByIdAndUpdate(
resultId,
{ writtenScore: newScore },
{ new: true }
);
And the grade just... didn't update. finalScore stayed stale. No error, no
warning, nothing in the logs. The document saved fine, the writtenScore
field updated fine — just the derived fields silently didn't.
Why this happens
Here's the thing I didn't fully internalize until I hit this: findByIdAndUpdate
(and findOneAndUpdate) talk to MongoDB directly through the driver's update
operators. They don't load a document into memory, mutate it, and call
.save() on it. Which means Mongoose's pre('save') and post('save')
middleware — the hooks tied specifically to the save lifecycle — never
fire. There's a separate pre('findOneAndUpdate') hook if you want middleware
on that path, but it's a genuinely different hook, and if you only defined
pre('save') (like I had), it's simply skipped.
This isn't a Mongoose bug. It's Mongoose behaving exactly as documented. The
bug was mine — I'd built a mental model where "updating a document" always
meant "the save hooks will run," and that assumption was wrong for one of the
two most common ways to update a document in Mongoose.
The fix: fetch, mutate, save
The reliable fix, when you need save-hook logic to actually run, is to stop
using the shortcut and go back to the explicit pattern:
const result = await Result.findById(resultId);
result.writtenScore = newScore;
await result.save(); // pre('save') hook fires here, as expected
Three lines instead of one, and it costs you an extra round trip to the
database. But it means your derived fields, validation, and any other
save-hook logic actually run every time, instead of only running for the
code paths that happen to call .save() directly.
What I actually changed after this
It wasn't just this one endpoint. I went back through the codebase and
audited every place I was using findByIdAndUpdate or findOneAndUpdate on
a model that had pre('save') middleware, because the bug is silent by
nature — it doesn't announce itself, it just produces stale derived data that
looks fine until someone notices the numbers are wrong.
The rule I settled on: if a schema has meaningful pre('save') logic
(derived fields, validation that depends on multiple fields, anything beyond
basic schema-level validation), don't reach for findByIdAndUpdate on it by
default. Use the fetch-mutate-save pattern, and reserve the findAndUpdate
shortcuts for simple field updates where nothing downstream depends on
save-hook side effects.
The actual lesson
The Mongoose docs cover this distinction clearly — it wasn't undocumented
behavior. The real lesson wasn't "read the docs more carefully," though
that's true. It was: when you reach for a shortcut method, know what it
skips, not just what it does. findByIdAndUpdate is genuinely useful and
I still use it constantly — just not on models where I need save-lifecycle
guarantees. The bug taught me to ask that question before writing the update
logic, not after debugging why a grade wasn't recalculating.
Top comments (2)
Have you considered using findOneAndUpdate instead, and what were some of the specific issues you encountered with findByIdAndUpdate? I'd love to swap ideas on this, following for more Mongoose insights.
Good question — actually findByIdAndUpdate is just a wrapper around findOneAndUpdate (with {_id: id} as the filter), so they share the exact same behavior here. Swapping to findOneAndUpdate wouldn't have fixed it — same bypass of pre('save')/post('save') hooks either way.
The two real options are:
Fetch-mutate-save (what I went with) — costs an extra DB round trip but guarantees save-hook logic runs
Define a separate pre('findOneAndUpdate') hook — since it covers both methods, but you lose access to this as the full document inside it (you're working with the query, not the doc, unless you explicitly fetch it via this.getUpdate() / a pre-hook query)
For derived fields like grade calculation I went with option 1 since correctness mattered more than the extra round trip. Curious what you've run into — have you had to use the findOneAndUpdate hook approach on anything where the round trip cost actually mattered?