Benchmarks for comparing MongoDB operations across:
- Monarch ORM
- Mongoose
- Native MongoDB driver
The suite uses tinybench and runs against a real MongoDB instance or falls back to an in-memory server via mongodb-memory-server when no MONGO_URI is set.
- Node.js 22+
- pnpm
pnpm installpnpm benchSet MONGO_URI in .env to run against a real MongoDB instance. Without it, an in-memory server is started automatically.
This runs:
- Initialization benchmarks (
initialization,connection) - Method benchmarks for schema sizes:
small,medium,large - Virtuals benchmarks (computed fields via
findOne/findMany) - Relations benchmarks (
findOne,findMany) with and without indexes, across populate styles (populateOne,populateMany,populateBoth) and multi-field variants
src/index.ts: benchmark entrypoint and suite orchestrationsrc/setup/: benchmark harness, runner, and schema-sized data builderssrc/db/: driver/ORM connection and model/schema setup (monarch.ts,mongoose.ts,native.ts)src/bench/init.ts: initialization benchmarkssrc/bench/methods/: per-method benchmarks (find,updateOne,aggregate, etc.)src/bench/virtuals.ts: virtuals (computed field) benchmarkssrc/bench/relations/: relation populate benchmarks (findOnePopulate*,findManyPopulate*)
pnpm bench: run benchmark suitepnpm check: TypeScript check + formatting checkpnpm format: check formatting with Prettierpnpm format:fix: apply Prettier formatting
All figures are median throughput (ops/s) — higher is better.
Environment: M2 MacBook Air, MongoDB v7.0.8 (local, Homebrew)
| Benchmark | Monarch | Mongoose |
|---|---|---|
| initialization | 1,362 | 737 |
| connection | 187 | 146 |
Monarch bootstraps ~85% faster than Mongoose. Connection setup is ~28% faster. This is a fixed cost per process, so the difference matters most in short-lived or serverless environments.
| Method | Monarch | Mongoose | Native |
|---|---|---|---|
| distinct | 12,645 | 12,164 | 13,058 |
| find | 10,073 | 7,913 | 10,603 |
| findById | 12,251 | 11,651 | 12,533 |
| findByIdAndUpdate | 12,397 | 9,864 | 12,931 |
| findByIdAndDelete | 11,911 | 8,882 | 12,201 |
| findOne | 12,390 | 10,174 | 12,632 |
| findOneAndReplace | 10,777 | 6,021 | 11,337 |
| findOneAndUpdate | 12,579 | 9,958 | 13,001 |
| findOneAndDelete | 12,429 | 9,717 | 17,937 |
| insertOne | 16,997 | 5,587 | 13,238 |
| insertMany | 1,647 | 695 | 6,032 |
| bulkWrite | 11,199 | 10,979 | 11,385 |
| replaceOne | 11,511 | 6,936 | 11,887 |
| updateOne | 12,685 | 11,696 | 12,938 |
| updateMany | 7,542 | 7,391 | 7,884 |
| deleteOne | 12,987 | 11,713 | 13,086 |
| deleteMany | 12,973 | 11,940 | 13,108 |
| aggregate | 4,348 | 4,578 | 4,747 |
| countDocuments | 9,934 | 9,438 | 10,101 |
| estimatedDocumentCount | 16,021 | 14,414 | 16,032 |
Monarch leads on almost every operation and closely tracks native. The biggest gaps are on writes: insertOne is ~3x faster than Mongoose, insertMany ~2.4x. aggregate, bulkWrite, distinct, and updateMany are roughly equal across all three.
| Method | Monarch | Mongoose | Native |
|---|---|---|---|
| distinct | 12,516 | 12,134 | 13,230 |
| find | 9,581 | 7,288 | 9,893 |
| findById | 11,934 | 8,972 | 12,390 |
| findByIdAndUpdate | 12,220 | 8,807 | 12,672 |
| findByIdAndDelete | 11,511 | 7,637 | 11,881 |
| findOne | 11,550 | 8,558 | 12,320 |
| findOneAndReplace | 9,441 | 4,802 | 10,017 |
| findOneAndUpdate | 12,257 | 8,801 | 12,632 |
| findOneAndDelete | 11,656 | 7,824 | 12,078 |
| insertOne | 11,550 | 4,259 | 12,552 |
| insertMany | 1,099 | 358 | 1,504 |
| bulkWrite | 11,096 | 10,934 | 11,147 |
| replaceOne | 10,213 | 5,600 | 11,178 |
| updateOne | 12,605 | 11,231 | 12,889 |
| updateMany | 7,762 | 7,315 | 7,871 |
| deleteOne | 12,931 | 11,045 | 13,216 |
| deleteMany | 12,841 | 11,057 | 12,980 |
| aggregate | 877 | 796 | 723 |
| countDocuments | 10,174 | 9,494 | 9,654 |
| estimatedDocumentCount | 15,989 | 14,109 | 16,260 |
The write advantage grows with schema size. insertOne is now ~2.7x faster than Mongoose, insertMany ~3x. Read operations maintain a 30–50% lead.
| Method | Monarch | Mongoose | Native |
|---|---|---|---|
| distinct | 12,882 | 11,639 | 13,333 |
| find | 9,167 | 5,916 | 9,360 |
| findById | 10,830 | 7,069 | 11,401 |
| findByIdAndUpdate | 11,326 | 6,369 | 11,184 |
| findByIdAndDelete | 10,811 | 5,636 | 11,050 |
| findOne | 11,289 | 7,143 | 11,348 |
| findOneAndReplace | 7,733 | 2,424 | 8,259 |
| findOneAndUpdate | 11,204 | 6,014 | 11,116 |
| findOneAndDelete | 10,148 | 5,643 | 14,679 |
| insertOne | 10,714 | 2,527 | 12,952 |
| insertMany | 523 | 222 | 1,057 |
| bulkWrite | 14,193 | 14,406 | 14,590 |
| replaceOne | 11,486 | 3,304 | 12,546 |
| updateOne | 16,925 | 10,652 | 12,821 |
| updateMany | 7,777 | 6,743 | 7,466 |
| deleteOne | 12,384 | 8,097 | 13,205 |
| deleteMany | 12,948 | 9,379 | 12,308 |
| aggregate | 710 | 711 | 660 |
| countDocuments | 9,852 | 9,136 | 9,543 |
| estimatedDocumentCount | 15,784 | 13,552 | 15,748 |
At large schema the write gap is most pronounced: insertOne is ~4x faster than Mongoose, insertMany ~2.4x, findOneAndReplace ~3.2x. aggregate and bulkWrite are tied across all three drivers.
| Benchmark | Monarch | Mongoose |
|---|---|---|
| findOne | 12,546 | 11,310 |
| findMany (n=10) | 9,007 | 4,991 |
| findMany (n=100) | 881 | 481 |
| findMany (n=1000) | 168 | 69 |
Monarch's virtual computation scales better per document. The gap grows with document count: +11% at findOne, +80% at n=10, +83% at n=100, and +143% at n=1000.
| Populate style | Monarch | Mongoose |
|---|---|---|
| populateOne | 8,236 | 4,116 |
| populateMany | 228 | 168 |
| populateBoth | 247 | 187 |
| populateOneMulti (3 refs) | 4,707 | 3,147 |
| populateManyMulti (3 FKs) | 76 | 147 |
| populateBothMulti (6 fields) | 80 | 147 |
populateOne is ~100% faster in Monarch. populateMany and populateBoth (single FK) are ~35% faster. The multi-field many/both cases reverse: Monarch is ~2x slower when resolving 3 foreign-key fields without indexes, as each field requires a full collection scan.
| Populate style | Monarch | Mongoose |
|---|---|---|
| populateOne | 7,308 | 4,421 |
| populateMany | 3,958 | 1,477 |
| populateBoth | 2,755 | 1,368 |
| populateOneMulti (3 refs) | 4,705 | 2,885 |
| populateManyMulti (3 FKs) | 847 | 486 |
| populateBothMulti (6 fields) | 866 | 458 |
Indexes unlock Monarch's populate advantage fully. populateOne is ~65% faster. populateMany and populateBoth are ~2–2.7x faster. Multi-field many/both are ~1.7–1.9x faster.
| Populate style | n | Monarch | Mongoose |
|---|---|---|---|
| populateOne | 10 | 850 | 1,419 |
| populateOne | 100 | 105 | 180 |
| populateMany | 10 | 94 | 81 |
| populateMany | 100 | ~1 | ~8 |
| populateBoth | 10 | 88 | 83 |
| populateBoth | 100 | ~1 | ~9 |
| populateOneMulti | 10 | 227 | 463 |
| populateOneMulti | 100 | 63 | 105 |
| populateManyMulti | 10 | 31 | 28 |
| populateManyMulti | 100 | ~0 | ~2 |
| populateBothMulti | 10 | 27 | 27 |
| populateBothMulti | 100 | ~0 | ~2 |
Without indexes, Mongoose wins all findMany scenarios. At n=100 with populateMany or populateBoth, Monarch reaches ~780ms per call vs ~117ms in Mongoose. The multi-field variants at n=100 are worse still (~2.75s vs ~402ms). populateOneMulti also shows Mongoose ~2x faster at both scales.
Warning:
populateManyandpopulateBothat n=100 without indexes reaches ~780ms–2.75s latency per operation in Monarch (vs ~117–405ms in Mongoose). Always use indexed relations when callingfindManywith foreign-key populate.
| Populate style | n | Monarch | Mongoose |
|---|---|---|---|
| populateOne | 10 | 349 | 641 |
| populateOne | 100 | 83 | 179 |
| populateMany | 10 | 168 | 61 |
| populateMany | 100 | 33 | 8 |
| populateBoth | 10 | 140 | 64 |
| populateBoth | 100 | 29 | 7 |
| populateOneMulti | 10 | 374 | 534 |
| populateOneMulti | 100 | 62 | 104 |
| populateManyMulti | 10 | 81 | 26 |
| populateManyMulti | 100 | 8 | 2 |
| populateBothMulti | 10 | 75 | 26 |
| populateBothMulti | 100 | 7 | 2 |
With indexes the winner depends on populate direction. For populateOne and populateOneMulti (parent holds the FK), Mongoose is ~2x faster. For populateMany, populateBoth, and their multi variants (child holds the FK), Monarch is ~2–4x faster at n=100.
Note:
populateOneandpopulateOneMultiare actually slower in the indexed collections than in the unindexed ones (for both ORMs). This is because these populate styles resolveposts.pinnedCommentId → comments._id, and_idis always indexed by MongoDB — the extra indexes on the indexed collections provide no benefit for this lookup direction, but do add overhead to the collection.