Mongoose 9.9 Cuts Its ODM Tax Nearly in Half
No rewrite, just disciplined bookkeeping that pulls Mongoose closer to the raw driver on the paths that matter.
Every ODM charges rent. You hand Mongoose a plain object, and in exchange for casting, validation, change tracking, middleware, getters, and transforms, it spends CPU you'd otherwise keep. On the project's own benchmark, the going rate on 9.8.1 for trivial inserts was roughly 2.5x the raw MongoDB Node.js driver, and the standard advice has long been to route around it: lean() on reads, collection.insertMany() on writes, Mongoose for everything in between.
Mongoose 9.9.0, released July 30, doesn't change that deal. It just makes the rent cheaper, and it does so in the least glamorous way possible: by fixing how the library does its own bookkeeping.
The numbers, reframed as overhead
The headline benchmark is the repo's own insertManySimple script, which inserts 1,500 one-field documents per batch, 200 timed iterations after a warm-up, with lean: false so Mongoose actually hydrates each document. The maintainer's runs:
| ms per batch | |
|---|---|
| mongodb@7.5.0 (driver only) | 5.31 |
| mongoose@9.8.1 | 13.58 |
| mongoose@9.9.0 | 8.75 |
The blog framing is "35% faster." The more useful framing is the overhead above the driver: 8.27 ms per 1,500 docs on 9.8.1, 3.44 ms on 9.9.0. That's the Mongoose tax cut by about 58%, from roughly 5.5 µs to 2.3 µs per document. The saveSimple benchmark (a 10-string-property schema, new Model().save() versus insertOne()) tells the same story at smaller scale: 2.61 ms to 2.35 ms against a 2.0 ms driver floor, so overhead drops from 0.61 ms to 0.35 ms per save.
Those aren't transformative numbers for a request that spends 40 ms waiting on the network. They are meaningful for the workloads where Mongoose overhead was ever visible: ingest workers, ETL jobs, seed scripts, anything that pushes tens of thousands of documents per second through a single Node process.
What actually changed
The interesting part of PR #16370 isn't any single trick; it's that nearly all of them are V8 hygiene rather than algorithmic changes.
The StateMachine that tracks modified paths used to reset itself with delete this.paths[key] in a loop. Deleting properties pushes V8 objects into dictionary mode and invalidates inline caches, which is a well-known deopt, but one that only hurts when it runs per document in a tight loop. insertMany() runs it per document. The replacement, clearAllExcept(), rebuilds fresh objects instead of mutating old ones.
Change-tracking state moved from new Map() to plain object literals, on the observation that constructing an empty object is far cheaper than constructing a Map in V8. The version key is now written through the internal $__setValue() with a pre-split path array rather than going through the full setter pipeline with its indexOf() and split() calls. parallelLimit(), the helper that bounds concurrency inside bulk operations, dropped its Set plus Promise.race() design for a fixed pool of worker functions sharing a nextIndex counter. Documents with only primitive values take a toObjectShallow() fast path instead of a deep clone. Hooks are skipped entirely when a schema registers none.
None of this bypasses a feature. That's the design constraint the maintainer set for the release, and it's why the upgrade is a plain minor with no migration notes.
The fix that matters more than the headline
The insertMany() work is the headline, but the toObject() fix is the one I'd upgrade for.
Issue #16373, filed July 7, documented toObject({ getters: true }) on projected documents scaling quadratically: for every schema path, Mongoose called isSelected(), which for unselected paths scanned every projection key. The reporter measured a 799x slowdown going from a 10-path to a 500-path schema. The fix in #16407 caches which paths actually have getters and skips the rest, plus swaps some startsWith() calls for slice() equality. The PR's benchmark at N=500 went from 12.63 ms per call to 0.72 ms.
That's the kind of bug that never shows in a microbenchmark and quietly eats a CPU core in production. If you have a wide schema, use select projections, and serialize with getters enabled (or have toJSON: { getters: true } in your schema options), you were paying this on every response. The pattern continued after 9.9.0: 9.9.3, shipped August 17, caches projection metadata on the document at construction time, with a contributor-measured 4.8x speedup on isSelected() for 500-key projections.
The timestamp change has a behavioral edge
The smallest change is the one most likely to surprise someone. Before 9.9, every updateOne() on a schema with timestamps: true added $setOnInsert: { createdAt } to the update, regardless of whether upsert was set. MongoDB ignores $setOnInsert on non-upserts, so this was pure waste, both in object construction and bytes on the wire.
Now the clause is only added when upsert: true is present. Semantically identical at the database. But if you have pre('updateOne') middleware that inspects this.getUpdate() and assumes $setOnInsert.createdAt is always there, that assumption is now false for non-upserts. Worth a grep.
How to adopt it
Upgrade. It's a minor version, the changes are internal, and the risk is low:
npm install mongoose@9.9
Then don't trust the benchmarks; run your own. The benchmarks/ directory in the Mongoose repo contains insertManySimple.js, saveSimple.js, findOneWithCast.js, and others, all self-contained scripts that time Mongoose against the driver. Swap in your real schema. The maintainer is explicit that nested documents, custom validators, middleware, getters, and transforms all have different characteristics, and he's right: a schema full of subdocument arrays hits code paths this release barely touched.
What this release doesn't change is the advice about lean. Lean queries skip hydration entirely, and the docs still put lean documents at roughly a third the memory footprint of hydrated ones. If your read path was already .lean(), 9.9 gives you almost nothing there. If you were dropping to Model.collection.insertMany() to avoid the tax on bulk writes, you can now weigh that trade-off again: you were giving up validation and middleware to save something like 5.5 µs per document, and the saving is now closer to 2.3 µs.
The bigger signal
The single most telling fact isn't a number. It's the cadence. 9.8.0 on July 20 bumped the driver to 7.5. 9.8.1 on July 27 was all perf, avoiding rebuilds of modified paths during validation. 9.9.0 on July 30. 9.9.2 inlined type checks in $__hasOnlyPrimitiveValues(). 9.9.3 cached projection metadata. 9.9.4, published today, indexes bulkSave() write errors by document ID instead of scanning. That's five consecutive releases with performance PRs, several from outside contributors, and an issue-to-fix turnaround of three weeks on #16373.
Mongoose has spent the last few years on ergonomics: async stack traces, cleaner middleware, stricter TypeScript in 9.0. It looks like the project has decided the next thing to compete on is the gap to the driver. That gap is still there, roughly 1.6x on trivial inserts, and it will never fully close, because casting and validation cost something. But a 9.x line that treats the driver as its benchmark rather than its escape hatch is a materially better library to build on than the one you were running a month ago.
Sources & further reading
- What's New in Mongoose 9.9: Major Performance Improvements — thecodebarbarian.com
- Mongoose CHANGELOG (9.8.0 through 9.9.4) — github.com
- InsertMany perf improvements (#16370) — github.com
- perf(document): faster string checks and unnecessary isSelected on paths with no getters (#16407) — github.com
- toObject({getters:true}) on projected documents causes O(N*M) complexity (#16373) — github.com
- perf(timestamps): avoid adding $setOnInsert for createdAt unless upsert set (#16411) — github.com
- perf(document): cache projection metadata for flat projections (#16439) — github.com
- benchmarks/insertManySimple.js — github.com
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 1
lean() and raw driver escapes finally unnecessary. curious if this changes the calculus for new projects or if it's still 'use prisma instead