Skip to content

We Benchmarked MongoDB vs FerretDB and Found Two Bugs in Our Own Platform

14 min readMongoDBFerretDBBenchmarksDatabases

A prospect evaluating us for a latency-sensitive, write-heavy workload asked a fair question: how does FerretDB actually compare to the MongoDB they run today? I did not have an honest answer. What I had was other people's numbers, mostly a well-known third-party run at 1 billion records where MongoDB wins writes by a wide margin. That result is real and I am not going to argue with it. It also is not the question I was asked. The prospect is not running a billion records on a benchmark rig. They are running a working set on a box they pay a flat monthly rate for.

So we measured on our own hardware. The benchmark then spent most of its time telling me about us instead of about MongoDB, which is why this post exists.

Two of the findings were misconfigurations in our fleet that had been quietly costing every customer performance. Three more were bugs in the benchmark harness itself, which meant the first full round of numbers was garbage and had to be thrown away. The MongoDB comparison, which is what I set out to get, ended up being the fourth most interesting thing I learned.

If you want the background on what FerretDB is, the MongoDB vs FerretDB comparison covers the wire protocol, the feature gaps, and the licensing difference. This post is only about how the two perform on one specific box.

The setup

Everything below ran on the same hardware profile: an OVH d2-8 instance, 4 vCPU and 8 GB of RAM, Debian 13, ZFS 2.3.2 with the ARC capped at 2 GiB. That is not a benchmark rig chosen to look good. It is the exact flavor behind our Dedicated 8GB preset at $65 a month, which is what a customer with this workload would actually buy.

  • YCSB 0.18.0-SNAPSHOT
  • 10 million records loaded, 1 million operations per run, 600 second cap per run
  • Workload C (100% read), workload B (95% read / 5% update), workload A (50% read / 50% update)
  • 16, 64 and 256 client threads
  • Write concern 1 on both sides
  • MongoDB 8.0.29 and FerretDB 2.7, one arm per box, boxes torn down afterward

A 10 million record YCSB dataset is roughly a 10 GiB working set. On an 8 GB box, that is deliberate. This profile does not fit in memory, so storage layout matters, and storage layout is exactly what I wanted to see.

The first bug was ours: 128K records over 8K pages

FerretDB stores documents as JSONB in PostgreSQL. PostgreSQL writes in 8 KiB pages. Our ZFS dataset that holds user data, tank/users, was sitting on the ZFS default recordsize of 128K.

An 8 KiB page write into a 128 KiB record is a read-modify-write of the whole 128 KiB record. Every dirty page. On every box in the fleet.

This is not an exotic finding. It is the first line of every "PostgreSQL on ZFS" tuning guide written in the last decade. We had simply never checked, because nothing was on fire. No customer had complained, no alert had fired, no dashboard was red. It was not hurting anyone visibly. It was just leaving a large multiple of read throughput on the floor, silently, for everyone.

The second bug was also ours: PostgreSQL on stock memory

While I was in there, I looked at the PostgreSQL configuration the FerretDB backend was running with. It was stock. shared_buffers at 128MB, which is the compiled-in default, on a box with 8 GB of RAM.

Same category of problem as the first: nothing broken, nothing alerting, just a database told to use a rounding error's worth of the memory it had been given.

Neither of these was a bug in the sense of a crash or a wrong answer. They were both defaults nobody had revisited, which is the kind of thing a benchmark is unreasonably good at finding, because a benchmark is the only workload that complains.

Then the harness lied to us

Round one finished. The numbers looked odd. Throughput barely moved between 16, 64 and 256 threads, which is not how a client-server database behaves unless something is wrong.

Three things were wrong.

YCSB silently ignored our thread count. We were passing -p threads=64. The property YCSB actually reads is threadcount. An unknown property is not an error in YCSB and produces no warning, so every single round-one run executed at the default: one thread. Every number in that round measured single-threaded performance while labeled otherwise.

The driver capped the connection pool at 100. The YCSB MongoDB binding uses the Java driver's default maxPoolSize of 100 unless you set mongodb.maxconnections. So the 256-thread rows are not 256 concurrent connections. They are 256 client threads queuing over roughly 100 connections. The cap applies to every arm equally, so the comparison stands, but "256" is a client-thread count and not a concurrency claim.

The CSV parser matched nothing. Our result extractor ran grep -E 'Throughput(ops/sec)' over the YCSB output. In an extended regex, those parentheses are a capture group, not literal characters, so the pattern searches for Throughputops/sec, which appears in no file we have ever generated.

Round one's relative deltas survived all of this, because every arm was broken identically. Its absolute numbers did not survive, and absolute numbers are the entire point of publishing a benchmark. We deleted them and re-ran everything on a corrected harness. Every number below is from the corrected generation.

The ablation

Once I knew the recordsize and the memory settings were both wrong, I could not just fix both and report the combined win. Two changes and one result tells you nothing about which change did the work.

So we ran an ablation: five fresh boxes, one variable each, identical loads. OVH bills these by the hour, so the whole thing cost about $5.55.

  • 16K recordsize alone was worth 3.7x to 4.3x over stock on reads at real concurrency. The single largest lever, and it is a one-line dataset property.
  • Memory tuning alone was worth roughly 1.0x on reads. Not a typo. On its own, against 128K records, it was actively negative at 256 threads, because more dirty buffers over a 16x write amplification factor produced checkpoint storms rather than throughput. This is the finding I would have gotten wrong if I had shipped both changes together and taken credit for the sum.
  • Both together, which is the profile we are now making the default, gave 4.0x to 4.3x on reads and 2.4x to 3.0x on the 50/50 workload against its stock control. Update p99 on the stock arm sat around 2 seconds and collapsed to somewhere between 0.1 and 0.8 seconds.
  • Relaxed commit durability owned writes: 4.3x to 5.3x over stock, with update p99 between 19 and 281 ms. This one is an explicit customer opt-in and will never be a default, because it trades the last instant of acknowledged writes if the machine loses power. That is a decision a customer gets to make about their own data, not one we make for them.

One oddity I cannot explain and am recording anyway: the relaxed-durability profile is about 0.9x the tuned default on pure reads. Reproducible across runs, no theory that survives contact with the data. It is why we label that opt-in as write-workload-only rather than as a general speed setting.

There was also a superseded arm: an earlier box using an 8K recordsize measured consistently below the 16K arms on all three workloads. 16K beat 8K, which is not what I expected going in, and is why the ablation existed.

The numbers

All of the following is the corrected harness generation, same hardware profile on both sides. Columns are MongoDB 8.0.29 stock, FerretDB 2.7 on our old stock configuration, FerretDB on the new tuned default with durability intact, and FerretDB with the relaxed-durability opt-in.

Workload C, 100% read, ops/sec

ThreadsMongoDBFerretDB stockFerretDB tuned defaultFerretDB durability opt-in
167558793,8883,458
647569464,0653,635
256 (pool)7539523,8513,522

Tuned default versus MongoDB: 5.1x to 5.4x.

Workload B, 95% read / 5% update, ops/sec

ThreadsMongoDBFerretDB stockFerretDB tuned defaultFerretDB durability opt-in
167008843,6113,624
647509313,9193,767
256 (pool)7129333,5263,386

Tuned default versus MongoDB: 5.0x to 5.2x.

Workload A, 50% read / 50% update, ops/sec

ThreadsMongoDBFerretDB stockFerretDB tuned defaultFerretDB durability opt-in
164914121,6312,916
644644911,7523,020
256 (pool)4653681,5862,795

Tuned default versus MongoDB: 3.3x to 3.8x. Durability opt-in versus MongoDB: 5.9x to 6.5x. Note the second column: our old stock FerretDB configuration is roughly at parity with MongoDB here, between 0.8x and 1.1x. The tuning is the story, not the engine.

Update p99 latency, workload A, milliseconds

ThreadsMongoDBFerretDB stockFerretDB tuned defaultFerretDB durability opt-in
1615620210019
646641,44738894
256 (pool)1,6822,529849281

Footnotes, which travel with the numbers

  1. "256 (pool)" is a client-thread count, not 256 connections. The YCSB MongoDB driver caps its pool at the default 100 unless mongodb.maxconnections is set, so 256 threads queue over roughly 100 connections on every column. The cap is symmetric and the comparisons stand, but true 256-connection concurrency was not exercised. Every arm saturates the 4 vCPU box by 64 threads anyway; nothing gains from 64 to 256.
  2. MongoDB is IO-bound on this hardware profile. A 2 GiB ARC plus a 3.5 GiB WiredTiger cache against a roughly 10 GiB working set. Its flat ceiling near 750 reads per second is a real result for this box class rather than a harness artifact, and it would improve on larger presets with more memory. Read every MongoDB column as "MongoDB on a memory-starved 8 GB box", because that is what it is.
  3. FerretDB stock ran PostgreSQL with max_connections=100, which is the stock configuration under test, so its 256-thread cells may also reflect connection limits.
  4. Cross-box note. MongoDB and stock FerretDB ran on boxes built on 2026-08-18; the tuned default and the durability opt-in ran on the 2026-08-19 ablation boxes. Same flavor, same pinned image inputs, same corrected harness generation.
  5. The durability opt-in is a write tool. It is about 0.9x the tuned default on pure reads, reproducible and unexplained, as noted above.

What that costs at MongoDB Atlas

We did not benchmark Atlas. I want to be completely clear about that before the table, because a price table next to a performance table invites a comparison I did not earn.

What I can compare is what you pay for the hardware. Atlas prices verified 2026-08-19 from mongodb.com/pricing, converted at 730 hours per month, compute only.

PlanRateMonthlyvCPU / RAM / Storage
Atlas M10$0.08/hrabout $582 / 2 GB / 10 GB
Atlas M20$0.20/hrabout $1462 / 4 GB / 20 GB
Atlas M30$0.54/hrabout $3942 / 8 GB / 40 GB
Layerbase Dedicated 4GBflat$352 / 4 GB / 25 GB
Layerbase Dedicated 8GBflat$654 / 8 GB / 50 GB
Layerbase Dedicated 16GBflat$1202 / 16 GB / 100 GB

The Atlas monthly figures are compute alone. Storage, backup and egress bill on top of them. Our prices are flat and include the storage listed.

The honest version of the comparison is this: MongoDB, running on a box with equal or better specs than an M30, did 464 operations per second on the 50/50 workload. Tuned FerretDB on our $65 box did 1,752 on the same workload on the same hardware profile. Whether Atlas at $394 a month does better than our self-hosted MongoDB did is a question this benchmark does not answer, and I am not going to pretend otherwise.

What I am not claiming

This is one hardware profile. Everything above is bounded by it.

  • The box is deliberately memory-starved. A 10 GiB working set on 8 GB of RAM is where storage layout dominates, which is precisely why it exposed our recordsize problem. On a box where the working set fits in memory, the gap between these arms will be much smaller and the ranking may change.
  • At 1 billion records the write picture inverts. The third-party data I mentioned at the top has MongoDB winning writes at that scale, and nothing here contradicts it. Our numbers describe a 10 million record working set on a 4 vCPU box, not a billion records on a benchmark cluster.
  • The architectures are not equivalent. Atlas M-tiers are three-node replica sets. Our dedicated preset is a single node with backups and branching. That is a price-for-workload comparison, not an architecture-parity comparison, and if you need a replica set today then Atlas has something we do not.
  • We did not run Atlas. Said twice on purpose.
  • These numbers describe the dedicated tier. Different storage path, different neighbors, different everything. Do not read them as "Layerbase performance" in general.
  • Our own earlier comparison said MongoDB likely wins write-heavy workloads, and I am not retracting that so much as bounding it. It holds at scale and on hardware where MongoDB is not IO-bound. It did not hold on this box, against a properly configured PostgreSQL backend, which is a narrower claim than either of us probably wanted.

What changes for customers

The 16K recordsize and the tuned memory profile are becoming the default. They are rolling out behind a staged canary rather than all at once, because a storage property change on a fleet is exactly the kind of thing that deserves to go slowly, and because the ablation showed the memory half is capable of being negative when it lands without the recordsize half. Existing databases pick it up as they move through the rollout. Nobody has to ask for it and nobody pays extra for it.

The relaxed-durability profile is coming as an explicit opt-in toggle for write-heavy workloads, with the tradeoff stated in plain language at the point where you flip it. It is a real 4x to 5x on writes and it can lose the last instant of acknowledged writes on a hard crash. Both of those sentences will be on the same screen.

And the thing Atlas does not offer at any tier: branching. A branch of your database, on the same wire protocol, for a migration rehearsal or a CI run or an agent that you would rather not point at production. If you already have data in MongoDB, the Atlas migration page copies collections, documents and indexes into a managed FerretDB database and verifies the counts against your source, and the migration guide covers the compatibility checks worth running before you commit.

To be exact about what is on offer: MongoDB itself is not creatable in Layerbase Cloud for licensing reasons, so the hosted document story is FerretDB, which is Apache 2.0 licensed, speaks the MongoDB wire protocol, and is built on the DocumentDB extensions that Microsoft released under MIT and donated to the Linux Foundation. MongoDB does run locally through Layerbase Desktop and the Layerbase CLI, which is how the MongoDB arm of this benchmark existed in the first place.

The standing offer

The most useful thing this benchmark produced was not a table. It was the discovery that we had been shipping a storage default nobody had audited, found only because someone asked a question we could not answer from memory.

So the offer stands: if you are evaluating us and your workload shape is not represented above, tell me what it looks like and we will run it on our hardware at our cost and send you the numbers, including the ones that do not flatter us. That is roughly $5 and an afternoon, and based on this week it pays for itself.

You can start a database at cloud.layerbase.com/create in the meantime.