Mongoose 9.9.0 was released on July 30, 2026 and is primarily focused on improving performance. We've been looking forward to taking a deep dive into JavaScript performance for many years and we're thrilled to share our first round of improvements. These changes reduce overhead in several of Mongoose's most heavily used code paths and will make most Mongoose apps a little bit faster with no changes required. This release includes several optimizations for insertMany(), document validation, and toObject().

Most importantly, these changes do not bypass casting, change tracking, validation, middleware, getters, transforms, or any other Mongoose feature that your app relies on. Mongoose is just now smarter about the bookkeeping it needs to do to support these features.

In this blog post, I'll cover the most important performance improvements in Mongoose 9.9, with a particular focus on insertMany().

Faster insertMany()

insertMany() is a convenient way to insert a large number of documents while still getting Mongoose casting, defaults, validation, timestamps, and middleware. Mongoose does additional work to validate and hydrate each document, but there was also a lot of overhead that we could eliminate. All in all, we've been able to improve Mongoose's performance on our insertManySimple benchmark by 35%.

Mongoose insertMany() 1500 documentsA bar chartMongoose insertMany() 1500 documentsMongoose 9.9.0 vs 9.8.1 vs MongoDB Node.js drivermongoose@9.8.1: 13.58ms13.58msmongoose@9.8.1mongoose@9.9.0: 8.75ms8.75msmongoose@9.9.0mongodb@7.5.0: 5.31ms5.31msmongodb@7.5.0ms

Here's some of the biggest pieces of overhead that we were able to eliminate:

Using empty objects {} instead of new Map()

Creating a new Map is 10x slower than creating an empty object - creating an empty object is heavily optimized in V8, even faster than using Object.create(null).

We also entirely got rid of originalDocIndex, which was one of the maps insertMany() used to track order. I originally started moving toward using new Map() to minimize risk of prototype pollution, but the performance tradeoff is too great to justify in performance-sensitive code.

Leveraging Mongoose's fast path toObjectShallow() function to avoid deep cloning for documents that only have primitives.

Mongoose's toObject() function deep clones the document. toObjectShallow() is a faster alternative that skips all recursive cloning - about 40% faster than toObject() because it avoids a bunch of unnecessary options, object checks, and recursive cloning.

We also reuse the check for whether the document has only primitive values to avoid checking for subdocuments when resetting isNew.

Faster setting of versionKey.

Before, insertMany() would initialize the version key using doc[versionKey] = 0, which looks fine at first glance... until you realize that doc[versionKey] actually triggers Mongoose's entire set() path, including setters and checking versionKey for . to potentially split the version key path.

Now, insertMany() uses Mongoose's internal $__setValue() function with a pre-split version key path to avoid the extra indexOf() check and general set() overhead. In particular, Mongoose documents' $__setValue() now has a fast path for when the given path is an array of length 1 - in this case, it simply sets the value directly without any indexOf() or split() overhead.

Faster parallelLimit() without sets

insertMany() uses a parallelLimit() function to ensure a maximum of options.limit validations can execute concurrently - this is helpful for async validators that make database queries. Previously, parallelLimit() used a Set to track pending validations: parallelLimit starts a validation, adds it to the set, waits for the next validation to complete using Promise.race(), and removes the completed validation from the set. Below is the old code.

const results = [];
const executing = new Set();

for (let index = 0; index < params.length; index++) {
  const param = params[index];
  const p = fn(param, index);
  results.push(p);

  executing.add(p);

  const clean = () => executing.delete(p);
  p.then(clean).catch(clean);

  if (executing.size >= limit) {
    await Promise.race(executing);
  }
}

return Promise.all(results);

Like with maps, creating a new Set() is prohibitively slow. Also, calling Promise.race() O(n) times is slow because Promise.race() creates a new promise every time and iterates over all pending promises. Instead of relying on a set, the new optimized insertMany() kicks off limit "worker" functions. Each worker() call executes a single validation, and recursively calls worker() when it succeeds. That means no set instantiation and no Promise.race(): parallelLimit() tracks the next validation to execute with a single nextIndex counter.

Change tracking improvements

Mongoose's state machine class is simple, but it sits on a very hot path: documents use it on every property set and after a document is persisted in insertMany() or save(). The StateMachine class stores:

  1. A states object that maps state names to a list of paths in that state
  2. A paths object that maps paths to their state - the inverse of states

The state machine class has a clear() method clears all paths from a given state.

StateMachine.prototype.clear = function clear(state) {
  if (this.states[state] == null) {
    return;
  }
  const keys = Object.keys(this.states[state]);
  if (keys.length === 0) {
    return;
  }
  this.states[state] = {};
  let i = keys.length;

  while (i--) {
    delete this.paths[keys[i]];
  }
};

While this function may look inocuous at first glance, it is actually quite expensive. Specifically, the delete in a loop is a massive JIT deopt. While the overhead for a single clear() call is negligible, Mongoose calls clear() twice for every single document in the insertMany(). When calling insertMany() on 1500 documents 10,000 times this small overhead adds up to a significant performance hit. However, we don't need to call clear() - the idea is that Mongoose calls clear() multiple times to clear almost all the change tracking for each document, minus the init state. So instead of calling clear() multiple times, we can define a separate clearAllExcept() method that clears all states except for the given state. This avoids the delete deopt.

StateMachine.prototype.clearAllExcept = function clearAllExcept(state) {
  // State buckets are created lazily, so `states[state]` may not exist yet.
  const bucket = this.states[state];
  const keys = bucket == null ? [] : Object.keys(bucket);
  if (keys.length === 0) {
    this.paths = {};
    this.states = {};
    return;
  }
  this.paths = {};
  for (const path of keys) {
    this.paths[path] = state;
  }
  this.states = {
    [state]: this.states[state]
  };
};

The change tracking improvements don't just apply to insertMany(); save() also calls clear() under the hood. Specifically, avoiding clear() combined with some optimizations to getting paths to validate() means creating and saving a new document with 10 properties is now about 10% faster, albeit still 15% slower than the MongoDB Node.js driver.

Mongoose save() vs insertOne()A bar chartMongoose save() vs insertOne()Mongoose 9.9.0 vs 9.8.1 vs raw MongoDB Node driver for creating a new document with 10 propertiesmongoose@9.8.1: 2.61ms2.61msmongoose@9.8.1mongoose@9.9.0: 2.35ms2.35msmongoose@9.9.0mongodb@7.5.0: 2ms2msmongodb@7.5.0ms

More efficient toObject()

Mongoose's toObject() and toJSON() methods are heavily used even when they aren't called directly by the application. For example, when you call JSON.stringify() on a Mongoose document, JSON.stringify() calls toJSON() internally. And Express.js' res.json() calls JSON.stringify() internally. Also, the save() function uses toObject() internally when creating a new document.

First, Mongoose now caches which schema paths have transforms. Before, even if your schema had no transforms, toObject() would still scan every path in your schema to check for transforms. In the common case where a schema has no transforms, toObject() sees that the list of transforms is null and skips the entire transform check. Checking for null is also slightly faster than checking if an array has length === 0.

Similarly, applying getters now skips paths that do not have getters before checking whether the path is selected by caching which paths have getters. Mongoose no longer needs to scan every path in your schema to check for getters. This improvement is even more significant when working with complex projections. In Mongoose 9.8, Mongoose would scan every path in your schema and check whether the path was included in the projection, which could be O(n*p) where n is the number of paths in your schema and p is the number of paths in the projection. Mongoose 9.9 avoids checking whether the path is included in the projection for paths that do not have getters - if your schema still has a lot of getters, like if you use global getters to automatically convert ObjectIds to strings, you would see less of a performance improvement.

Timestamp updates only add createdAt for upserts

When applying timestamps to an update, Mongoose needs to set updatedAt on every update. However, $setOnInsert is guaranteed to be ignored if upsert is not set, so setting createdAt in $setOnInsert without upsert is a waste of memory and bandwidth. Mongoose 9.9 now avoids adding $setOnInsert.createdAt to ordinary updates:

const userSchema = new mongoose.Schema({ name: String }, { timestamps: true });
const User = mongoose.model('User', userSchema);

// No longer sets `createdAt` in `$setOnInsert`
await User.updateOne(
  { name: 'John' },
  { $set: { name: 'John Smith' } }
);

// Only add `createdAt` to `$setOnInsert` if `upsert: true` is set
await User.updateOne(
  { name: 'John' },
  { $set: { name: 'John Smith' } },
  { upsert: true }
);

This produces an update with updatedAt, but does not add a createdAt value that can never be used by the non-upsert operation. For an upsert, Mongoose still adds createdAt to $setOnInsert as expected.

Moving On

Mongoose 9.9 features widespread performance improvements that will help most Mongoose apps run a little bit faster. The changes we made are all fairly small, but they add up to a noticeable performance boost in insertMany() and document serialization, particularly in documents with no nested paths.

As always, if you are upgrading a production application, run your existing test suite and test it out for yourself. The benchmarks in this release are intentionally simple; schemas with nested documents, custom validators, middleware, getters, and transforms will have different performance characteristics.

Install Mongoose 9.9 and try it out for yourself!

npm install mongoose@9.9
Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus