Skip to content

Benchmarks

How well does Mongoat's thin-ODM bet hold up? This page answers with a reproducible benchmark: @iamcalegari/mongoat measured against the native MongoDB driver, Mongoose, and Papr. Every library is installed from npm at a pinned version, and all four drive the same MongoDB running in Docker.

Dependencies over the driver
+0
Mongoat installs nothing beyond the mongodb driver you already pull in. Papr is also +0; Mongoose adds 8 packages, including a second bundled copy of the driver.
Runtime overhead vs. the driver
≈ 0
On all ten operations mongoat's throughput sits within measurement noise of the raw driver it wraps. Zero overhead is the thin-ODM goal, and the benchmark confirms it.
Blocks $where by default
1 of 4
Only mongoat. The native driver, Mongoose and Papr all run attacker-supplied server-side JavaScript out of the box.
Mongoose on batch operations
≈3× slower
insertMany(1000) runs at 31% of native and findMany(100) at 51% — the paths where Mongoose hydrates and validates every document.

Two of those figures are where Mongoat pulls clearly ahead: zero added dependencies, and the only default block on $where injection. The third — tying the native driver on every operation — looks like a non-result until you recall what a thin ODM is for. Zero measurable overhead is the goal, and the benchmark confirms you don't pay for the ergonomics at runtime. What the numbers can't do is rank the thin libraries against each other on speed, because their gaps fall below the noise floor of anything that talks to a database over a socket. So this page leads with the two dimensions that genuinely separate the field, security and footprint, and treats speed as what it is: a tie among the thin libraries, with Mongoose the lone outlier on batch work.

Security posture

This suite is not timed. It hands each library the kind of hostile payload that arrives in an HTTP body and records what the library actually does with it, observed rather than assumed. The + sanitizeFilter column is mongoat with its opt-in sanitizeFilter applied at the call site.

Hostile payloadnativemongoat+ sanitizeFiltermongoosepapr
$where (top level)✕ allowed✓ blocked· neutralised✕ allowed✕ allowed
$where (nested in $and)✕ allowed✓ blocked· neutralised✕ allowed✕ allowed
$function (inside $expr)✕ allowed✕ allowed· neutralised✕ allowed✕ allowed
Operator injection (nested $ne)✕ allowed✕ allowed✕ allowed✕ allowed✕ allowed
Operator injection (top-level $ne)✓ server-rejected✓ server-rejected✕ allowed✓ server-rejected✓ server-rejected
Wrong type on insert✕ allowed✓ server-rejected✓ server-rejected✓ blocked✓ server-rejected
Unknown field on insert✕ allowed✓ server-rejected✓ server-rejected· dropped✓ server-rejected

Reading the matrix:

  • Only mongoat blocks $where by default. Its always-on $where guard stops server-side code execution even when the operator is buried inside $and. The other three run the attacker's JavaScript.
  • Server-side $jsonSchema validation catches bad writes. Mongoat and Papr push validation to the database, so a wrong-typed field or an undeclared property is rejected by MongoDB itself. Mongoose validates in the application process, blocking the wrong type and silently dropping the unknown field. The bare driver accepts both.
  • Two gaps stay open in mongoat's defaults, and they belong on this page.$function inside $expr is a code-execution vector the always-on guard does not cover; only the opt-in sanitizeFilter does. And nested operator injection ({ email, name: { $ne: … } }, the classic auth bypass) slips past every library here, sanitizeFilter included, because the sanitiser only strips top-level $-keys.
  • sanitizeFilter has a sharp edge worth knowing. It neutralises all three code-execution operators at any depth, which is why the $where rows read "neutralised". But look at the top-level $ne row: removing the operator turns { $ne: null } into {}, which matches every document, a filter the server would otherwise have rejected outright. Stripping a key can make a filter more permissive, not less. Use it deliberately, on untrusted input, and back it with server-side validation.

No library here is invulnerable, mongoat included. What the matrix shows is that mongoat ships the strongest default posture of the four — the only $where block, plus server-side validation — while staying upfront about the input you still have to sanitise yourself.

Install footprint

This is the one section with no noise caveat: the numbers are static and exact. What matters is how much each library adds on top of the mongodb driver, since every one of them pulls the driver in anyway.

LibraryTotal depsAdded over driverOwn size
mongodb (native)18— (baseline)3.27 MB
mongoat19+00.15 MB
papr19+00.12 MB
mongoose25+87.42 MB

Mongoat and Papr add nothing beyond the driver. Mongoat hard-depends on mongodb, Papr peer-depends on it, and neither carries anything else. Mongoose adds eight packages, among them its own bundled mongodb@7.2.0 and bson@7.3.1, a second copy of the driver sitting alongside the one already in your tree.

paprpapr — papr: 0.12 MB0.12 MBmongoatmongoat — mongoat: 0.15 MB0.15 MBmongodb (native)native — mongodb (native): 3.27 MB3.27 MBmongoosemongoose — mongoose: 7.42 MB7.42 MB

For a library whose core value is staying thin, this is the cleanest confirmation of the whole thesis: mongoat's own code is 150 KB, and it costs you zero extra transitive dependencies to audit.

Throughput vs. the native driver

Now the speed picture, and the caveat that governs it. Every throughput result carries a spread: how far the fastest and slowest of the five rounds fell from the median. That spread is the benchmark's own noise floor, and on this hardware it is wide. Re-run the same operation a few minutes later and it routinely moves by 10 to 56%.

The spread is not sampling error. Within a single round, tinybench's margin of error over ~700 samples has a median of 1.3%, so each measurement is tight. The movement comes from host and container drift between rounds, which is why running every case six times longer than the smoke test tightened nothing. The gate is therefore the between-round spread: a gap narrower than the spread is not a result, and naming a winner inside it would be inventing one.

Most operations here are noise-bound by construction. A round-trip to the server costs a few milliseconds; the ODM's own work costs microseconds, comfortably under 1% of what the clock sees. The ones that do resolve are the batch operations, where per-document cost is amortised over a single round-trip and the library's work finally rises above the I/O.

Each bar below is a library's median throughput as a percentage of the native driver on the same operation. The shaded tie zone spans the baseline give or take the run's median spread, so any bar inside it ties the raw driver. The two operations where Mongoat's edge is real are the batch ones, marked in green: Mongoat holds native-level throughput while Mongoose pays document by document.

Writes

tie zonenative driver = 100%mongoatpaprmongooseinsertOnemongoat — insertOne: 97%97%papr — insertOne: 98%98%mongoose — insertOne: 85%85%insertMany(1000)≈ native · ~3× Mongoosemongoat — insertMany(1000): 87%87%papr — insertMany(1000): 93%93%mongoose — insertMany(1000): 31%31%findOneAndUpdatemongoat — findOneAndUpdate: 96%96%papr — findOneAndUpdate: 103%103%mongoose — findOneAndUpdate: 92%92%updateManymongoat — updateMany: 98%98%papr — updateMany: 97%97%mongoose — updateMany: 91%91%findOneAndDeletemongoat — findOneAndDelete: 101%101%papr — findOneAndDelete: 97%97%mongoose — findOneAndDelete: 93%93%

Reads

tie zonenative driver = 100%mongoatpaprmongoosefindOnemongoat — findOne: 82%82%papr — findOne: 96%96%mongoose — findOne: 96%96%findMany(100)≈ native · ~2× Mongoosemongoat — findMany(100): 99%99%papr — findMany(100): 98%98%mongoose — findMany(100): 51%51%aggregatemongoat — aggregate: 119%119%papr — aggregate: 105%105%mongoose — aggregate: 97%97%countmongoat — count: 104%104%papr — count: 100%100%mongoose — count: 98%98%

Full numbers, each with its own spread and verdict:

Operationnative (ops/s)mongoatpaprmongooseVerdict
insertOne24597%98%85%tie
insertMany(1000)3087%93%31%Mongoose ≈3× slower
findOne (indexed)109382%96%96%tie
findMany(100, indexed)48599%98%51%Mongoose ≈2× slower
findOneAndUpdate23596%103%92%tie
updateMany50098%97%91%tie
aggregate (group+sort)81119%105%97%tie
count (indexed)916104%100%98%tie
findOneAndDelete231101%97%93%tie
transaction (commit, 10 docs)17699%97%82%tie

Those two Mongoose results reproduce across every run so far — insertMany came in at 26%, 37%, and 31% on three separate runs — so they are quoted as "≈3×" rather than a single figure: the direction holds, the exact magnitude drifts. Everything else is a tie. On all ten operations, neither mongoat nor Papr can be told apart from the driver they sit on.

Why does an ODM sometimes read above 100%?

Mongoat's aggregate shows 119% and its count 104%. An ODM calls the native driver, so it cannot really outrun it; those are the noise band doing its job, and both sit well inside the tie zone. An earlier, biased version of this harness printed ODMs at 127% of native as a headline. That impossible number is what exposed the measurement as bent and drove the fairness fixes under Method.

Transactions

All four libraries commit at the same speed — the transaction row above is a tie — and, the part that actually matters, all four roll back correctly. An aborted transaction was verified to leave zero documents behind and to surface the error to the caller, every time:

LibraryAborted transactionError propagated
nativeclean rollback (0 survived)yes
mongoatclean rollback (0 survived)yes
mongooseclean rollback (0 survived)yes
paprclean rollback (0 survived)yes

Rollback is pass/fail, not a stopwatch. A fast transaction that keeps its writes after an abort is worse than a slow correct one, so correctness settles before speed.

Mongoat's edge here is surface, not speed. Passing { session } to a mongoat write keeps it on the guarded API: server-side validation, the $where guard, allowedMethods gating and your pre/post hooks all run inside the transaction, exactly as they do outside it (how-to). Drop to the native driver's raw session, as native and Papr require, and those checks go with it; Mongoose keeps its own client-side validation on that path.

Method

The numbers are only worth as much as the method behind them, and a cross-ODM benchmark is easy to get wrong. The first working version of this harness reported ODMs at an impossible 127% of the native driver, which is what surfaced the biases below.

Environment

This run was executed on the setup below. Absolute throughput will differ on other hardware; the percentages and the footprint numbers are what travel.

Date2026-07-17
Node.jsv22.22.2
CPUAMD Ryzen 9 4900HS · 16 threads
Memory16 GB
MongoDBmongo:7 (single-node replica set, via @testcontainers/mongodb)
Sampling5 rounds × 3s per case, medians reported

Versions under test

Every library is the published npm release, never a local build:

PackageVersion
@iamcalegari/mongoat1.1.0
mongodb (shared driver)7.0.0
mongoose9.7.4
mongodb bundled inside mongoose7.2.0
papr17.1.0

How fairness is kept

Each of these is load-bearing; remove any one and the results bend:

  • One server for everyone. A single pinned mongo:7 container, so no result can be blamed on a different server version, storage engine, or host.
  • The server is warmed before anything is measured, so whichever adapter runs first doesn't pay for a cold cache.
  • Every case gets a freshly dropped, re-seeded database. Otherwise earlier insert cases grow the collection that later reads scan, and a faster adapter inserts more documents, leaving itself a bigger collection to read. Shared state would penalise exactly the adapters the benchmark should reward.
  • Adapter order rotates every round, spreading any residual order effect evenly across all four instead of always favouring the same one.
  • Medians across rounds, not means, so one container hiccup can't move a result, and every result carries its round-to-round spread.
  • Identical documents, indexes, and pipelines for every library, drawn from a seeded PRNG: same fields, same BSON types, same values, every run.
  • Equivalent operations. Mongoat's update/delete return the affected document, so every other adapter uses the returning variant too. Comparing updateOne against findOneAndUpdate would be comparing different work.

Each library otherwise runs with its idiomatic defaults, because that is what its users actually get: Mongoose hydrates documents and validates client-side; mongoat and Papr validate server-side; the native driver does neither.

Where the comparison is still imperfect

No benchmark is neutral. These are the seams in this one:

  • Mongoose talks to the server through its own bundled driver copy (7.2.0), while the other three share the top-level 7.0.0. Both versions are recorded above.
  • The container shares a host with the benchmark process. This is the main source of the between-round spread; a dedicated server would tighten it.
  • insertOne for Mongoose uses create(), its idiomatic path, which does more than the driver's insertOne by design.

Reproducing it

The harness lives in a companion project, mongoat-benchmarks, built so anyone can clone it and re-run the whole thing:

bash
npm install
npm run bench        # the full run above (~15 min); writes results/*.json
npm run bench:quick  # a ~3-min smoke test — never quote it as data

It needs only Docker running and a supported Node.js version. Each result is written to a versioned JSON file recording the environment, the exact library versions, every round, and the aggregated medians, so any number on this page can be audited against the raw data that produced it.