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 (4)
Virtuals might save you the next audit entirely. finalScore and grade are pure functions of the two stored scores, so if you define them as Mongoose virtuals they can never go stale and no hook has to fire, which makes findByIdAndUpdate stop being a trap on that model. The trade is that you can't query or index them in Mongo without an aggregation, so if "find every student with grade A" has to run in the database, stored fields still win. But for anything that's only computed on the way out, deriving on read deletes the whole class of bug instead of guarding every write path one by one. The fetch, mutate, save rule still feels right for everything that genuinely has to be stored.
This is exactly right, and it's a better fix for the general case — deriving on read removes the failure mode entirely instead of me having to remember to guard every write path.
For this particular model though I think stored fields still win: getBestStudentPerTerm and top-10 ranking both need to sort/filter by finalScore at the query level, and virtuals aren't real MongoDB fields — you'd need an aggregation $addFields step to get there, which is more machinery than just keeping it stored and disciplined about the write path. But for anything I'm only ever computing to display — never to query against — virtuals are clearly the better default. Appreciate this, going to be more deliberate about which category a derived field falls into before I default to storing it.
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?