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.
$where by defaultTwo 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 payload | native | mongoat | + sanitizeFilter | mongoose | papr |
|---|---|---|---|---|---|
$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
$whereby default. Its always-on$whereguard stops server-side code execution even when the operator is buried inside$and. The other three run the attacker's JavaScript. - Server-side
$jsonSchemavalidation 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.
$functioninside$expris a code-execution vector the always-on guard does not cover; only the opt-insanitizeFilterdoes. And nested operator injection ({ email, name: { $ne: … } }, the classic auth bypass) slips past every library here,sanitizeFilterincluded, because the sanitiser only strips top-level$-keys. sanitizeFilterhas a sharp edge worth knowing. It neutralises all three code-execution operators at any depth, which is why the$whererows read "neutralised". But look at the top-level$nerow: 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.
| Library | Total deps | Added over driver | Own size |
|---|---|---|---|
| mongodb (native) | 18 | — (baseline) | 3.27 MB |
| mongoat | 19 | +0 | 0.15 MB |
| papr | 19 | +0 | 0.12 MB |
| mongoose | 25 | +8 | 7.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.
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
Reads
Full numbers, each with its own spread and verdict:
| Operation | native (ops/s) | mongoat | papr | mongoose | Verdict |
|---|---|---|---|---|---|
| insertOne | 245 | 97% | 98% | 85% | tie |
| insertMany(1000) | 30 | 87% | 93% | 31% | Mongoose ≈3× slower |
| findOne (indexed) | 1093 | 82% | 96% | 96% | tie |
| findMany(100, indexed) | 485 | 99% | 98% | 51% | Mongoose ≈2× slower |
| findOneAndUpdate | 235 | 96% | 103% | 92% | tie |
| updateMany | 500 | 98% | 97% | 91% | tie |
| aggregate (group+sort) | 81 | 119% | 105% | 97% | tie |
| count (indexed) | 916 | 104% | 100% | 98% | tie |
| findOneAndDelete | 231 | 101% | 97% | 93% | tie |
| transaction (commit, 10 docs) | 176 | 99% | 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:
| Library | Aborted transaction | Error propagated |
|---|---|---|
| native | clean rollback (0 survived) | yes |
| mongoat | clean rollback (0 survived) | yes |
| mongoose | clean rollback (0 survived) | yes |
| papr | clean 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.
| Date | 2026-07-17 |
| Node.js | v22.22.2 |
| CPU | AMD Ryzen 9 4900HS · 16 threads |
| Memory | 16 GB |
| MongoDB | mongo:7 (single-node replica set, via @testcontainers/mongodb) |
| Sampling | 5 rounds × 3s per case, medians reported |
Versions under test
Every library is the published npm release, never a local build:
| Package | Version |
|---|---|
@iamcalegari/mongoat | 1.1.0 |
mongodb (shared driver) | 7.0.0 |
mongoose | 9.7.4 |
mongodb bundled inside mongoose | 7.2.0 |
papr | 17.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:7container, 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/deletereturn the affected document, so every other adapter uses the returning variant too. ComparingupdateOneagainstfindOneAndUpdatewould 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-level7.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.
insertOnefor Mongoose usescreate(), its idiomatic path, which does more than the driver'sinsertOneby design.
Reproducing it
The harness lives in a companion project, mongoat-benchmarks, built so anyone can clone it and re-run the whole thing:
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 dataIt 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.