Preface
SoloDB has grown from a simple SQL StringBuilder wrapper to a thread-safe, type-safe and optimizing SQL IR pipeline with a new explicit .Compile(...) caching layer to avoid re-generating the SQL every LINQ call.
Having published the new feature in SoloDB 1.3.0, I decided to re-run the old benchmark against LiteDB and, while at it, also compare it with an equivalent ACID MongoDB setup — I know that "ACID" and "MongoDB" do not mix very well from experience.
I have tested filters, exact counts, paging, grouped totals, updates and file operations. These are the calls an application makes every day, so the comparison includes the code, the returned results and the work needed to configure each database.
The contenders are SoloDB 1.3.0, LiteDB 5.0.21 and MongoDB Community 8.3.11, all called from C#. MongoDB uses its official 3.11.2 driver. SoloDB and LiteDB run inside the application; MongoDB runs on the same machine as a separate server. Each gets the same documents and the same requested results, using its own native APIs.
The workload
The fixture contains 200,000 events with eleven populated properties, explicit IDs and roughly 620–930 characters of body text. Systems and machines cycle through five values, aggregate numbers through four. These are broad, correlated filters. Each database starts with indexes on System, MachineName, AggId, UnixTimestamp, Type and Username.
Pages contain 100 complete objects. Every field and the order are checked against an in-memory oracle. Timestamps are unique, so all three databases sort by timestamp alone. Counts are exact and fresh. An additional compound-index experiment has its own results below.
Each query workload has an ordinary API and, where it changes execution, a prepared or tuned alternative. Both are warmed, then measured in alternating order on unchanged data. The engine order is repeated in reverse. The main query and write tables combine six measured samples per variant; raw samples retain the two blocks separately. Preparation and common-index construction each have two setup observations, one per block.
The query tables compare the ordinary API with the fastest verified implementation found for each operation. “Lead over second” compares each database’s best result using 100 × (runner-up time / winning time − 1).
The machine is a Ryzen 9 5900HS running Linux and .NET 9. Benchmark storage is /tmp on tmpfs. These are memory-backed filesystem measurements. MongoDB's binaries, database and logs also live under /tmp in a disposable Bubblewrap process.
Failures and setup costs
Because a correct and equivalent result is worth more than a faster one, every database here is configured to match the full transactionality SoloDB has by default and cannot be configured away. These are the problems I encountered with the versions tested here.
MongoDB: transactions require replica-set setup
A standalone MongoDB server does not support multi-document transactions. Even for this local, single-server benchmark, I had to configure and initialize a replica set before using them. MongoDB supports transactions on replica sets and sharded clusters; the restriction is the standalone deployment. MongoDB's transaction requirements describe that distinction.
The launcher uses --replSet benchmark, and the runner initializes the set and waits for a primary. Inserts and updates then use WithTransaction with snapshot reads and journaled majority writes. Calling InsertMany alone would not give the whole batch the atomicity tested here. Separate transaction checks verified rollback, committed changes and snapshot behavior.
MongoDB: a 2 GiB cache could not hold the insertion transaction
The 200,000-document atomic insert failed with code 388. The error said the transaction was too large to fit in the storage-engine cache. The driver threw MongoBulkWriteException, and the benchmark terminated.
I raised the WiredTiger cache from 2 GiB to 4 GiB, after which the full transaction succeeded. Splitting the insert into smaller commits would have changed the operation's atomicity, so that was not the workaround used. Having to size a server cache around a transaction is a real deployment consideration, even on a machine with memory available outside that cache.
LiteDB: a grouped count broke the open database instance
With LiteDB 5.0.21, this grouped query failed:
var groups = lite.Query()
.GroupBy("SUBSTRING(Username,0,1)")
.Select("{key:@key,n:COUNT(*.Username)}")
.ToList();
LiteDB's expression API supports counting a sequence of values. Here it raised LiteException: page type must be index page. This is an internal page-type error from a read query, not a useful explanation of what an application should change. Closing the database afterwards also failed with ObjectDisposedException: Cannot access a closed file. The failure therefore affected more than the query: the instance could no longer complete its normal close/checkpoint path.
Using COUNT(*) worked, and that is the grouping query measured in the tables. I also think this is fixed in the dev version of LiteDB.
GridFS: seeking needs an option, and file edits need replacement
The default GridFS download stream rejected changing Position. Offset reads worked after enabling GridFSDownloadOptions.Seekable. That setting is easy to miss when the API otherwise looks like a normal stream.
Counts: let the database count
Lets count some documents.
| Operation | SoloDB Ordinary | SoloDB Optimized | LiteDB Ordinary | LiteDB Optimized | MongoDB Ordinary | MongoDB Optimized | Lead over second |
|---|---|---|---|---|---|---|---|
| Count all documents | 0.37 | 0.20 | 69.44 | 69.44† | 45.07 | 22.40 | +10,890.6% |
| Count one prefix | 3.17 | 2.97 | 67.99 | 18.05 | 5.32 | 5.32† | +79.3% |
| Count two prefixes | 38.83 | 38.56 | 310.64 | 310.64† | 84.22 | 84.22† | +118.4% |
| Count unindexed tag | 101.31 | 101.11 | 332.53 | 312.56 | 69.19 | 69.19† | +46.1% |
Milliseconds; lower is faster. † Same API as ordinary.
Ordinary SoloDB and LiteDB calls both accept typed predicates:
long soloCount = solo.LongCount(x => x.System.StartsWith(prefix));
long liteCount = lite.LongCount(x => x.System.StartsWith(prefix));
For a repeated SoloDB query, compile the same terminal directly:
var count = solo.Compile((IQueryable<Event> q, string prefix) =>
q.LongCount(x => x.System.StartsWith(prefix)));
long matching = count("ui console");
That delegate retains translation, planning and binding machinery. It runs a fresh count on every call. Compile also accepts First, Single, aggregates and materializers.
LiteDB has a useful covered-count formulation:
var count = lite.Query()
.Where(x => x.System.StartsWith(prefix))
.Select("{n:COUNT(*.System)}");
long matching = count.Single()["n"].AsInt64;
The string is a LiteDB Bson expression. Every matching System in this fixture is present and non-null, so counting those values gives the requested document count. The LiteDB alternative uses these covered projections where applicable. Count-all uses the ordinary exact count in both columns; there is no separate optimization to advertise there.
MongoDB uses a prefix regex, with the input escaped:
var filter = Builders<Event>.Filter.Regex(x => x.System,
new BsonRegularExpression("^" + Regex.Escape(prefix)));
long ordinary = mongo.CountDocuments(filter);
long hinted = mongo.CountDocuments(filter,
new CountOptions { Hint = "System_1" });
For the unfiltered exact count, the tuned MongoDB call hints _id_, as recommended in the driver documentation. EstimatedDocumentCount is not used.
The two-prefix row applies both a machine prefix and an aggregate-number prefix. In the main table it has only the common single-field indexes; no contender quietly gets an extra compound index.
Four callers at once
| Operation | SoloDB Ordinary | SoloDB Optimized | LiteDB Ordinary | LiteDB Optimized | MongoDB Ordinary | MongoDB Optimized | Lead over second |
|---|---|---|---|---|---|---|---|
| Four workers × 50 exact prefix counts | 145.50 | 142.55 | 4,579.79 | 1,194.75 | 258.67 | 258.67† | +81.5% |
Milliseconds; lower is faster. † Same API as ordinary.
Four tasks each perform fifty fresh prefix counts. Each LiteDB worker owns its mutable retained builder. The compiled SoloDB delegates bind invocation values separately. The MongoDB calls use the shared client connection pool.
Pages that change between calls
| Operation | SoloDB Ordinary | SoloDB Optimized | LiteDB Ordinary | LiteDB Optimized | MongoDB Ordinary | MongoDB Optimized | Lead over second |
|---|---|---|---|---|---|---|---|
| First page | 6.00 | 5.64 | 129.43 | 129.43† | 1.83 | 1.72 | +228.4% |
| Page at offset 5,000 | 14.82 | 14.12 | 134.13 | 134.13† | 34.55 | 24.57 | +74.0% |
| Ten sequential pages | 65.66 | 65.66† | 1,307.29 | 1,307.29† | 31.87 | 31.38 | +109.2% |
| Ten pseudorandom pages | 202.51 | 199.89 | 1,310.24 | 1,310.24† | 1,067.56 | 1,060.72 | +430.7% |
Milliseconds; lower is faster. † Same API as ordinary.
The ordinary SoloDB page is familiar LINQ:
var rows = solo.Where(x => x.System.StartsWith(prefix))
.OrderByDescending(x => x.UnixTimestamp)
.Skip(offset).Take(100).ToList();
The retained version takes the changing values as arguments:
var page = solo.Compile(
(IQueryable<Event> q, string prefix, int offset) =>
q.Where(x => x.System.StartsWith(prefix))
.OrderByDescending(x => x.UnixTimestamp)
.Skip(offset).Take(100));
var rows = page("ui console", 5000).ToList();
The same delegate serves the first page, a deeper page, ten sequential requests and ten fixed-seed pseudorandom requests. Materialization stays inside every timing.
LiteDB can retain its own builder and change the offset after consuming each result:
var page = lite.Query()
.Where(x => x.System.StartsWith(prefix))
.OrderByDescending(x => x.UnixTimestamp)
.Limit(100);
var rows = page.Offset(offset).ToList();
That builder is mutable. It is used sequentially here, not shared between simultaneous requests.
MongoDB's tuned variant retains its filter and sort definitions and requests the existing timestamp index:
var rows = mongo.Find(filter,
new FindOptions { Hint = "UnixTimestamp_1" })
.SortByDescending(x => x.UnixTimestamp)
.Skip(offset).Limit(100).ToList();
A hint is workload-specific: this fixture distributes matching events throughout timestamp order. An application with a different distribution should check its own plan. SoloDB's compiler chooses its strategies within the library; LiteDB's retained builder and MongoDB's explicit options are different kinds of preparation, which is why the tables name the actual calls.
Client setup and query preparation
| Database | Client setup work | Once per retained set, ms |
|---|---|---|
| SoloDB | Compile four counts, a page, a point lookup and grouping | 137.20 |
| LiteDB | Construct native query builders and Bson projections | 22.07 |
| MongoDB | Construct client filters, options, sort and pipeline definitions | 8.68 |
The setup covers seven read shapes: four counts, one parameterized page, one point lookup and one grouped query.
SoloDB runs Compile for each shape. It translates the expressions, inspects index metadata, chooses its SQL strategies and builds invocation delegates. LiteDB constructs query builders and Bson projections. MongoDB constructs filter, sort, hint and aggregation definitions on the client; it has not sent those queries to the server or asked the server to plan them.
These are different amounts of work. Read the table as the startup cost of each selected API. Anything left until invocation is included in the read timings. An application pays this setup once when it creates the retained set, then reuses it as parameters change.
Point reads and grouping
| Operation | SoloDB Ordinary | SoloDB Optimized | LiteDB Ordinary | LiteDB Optimized | MongoDB Ordinary | MongoDB Optimized | Lead over second |
|---|---|---|---|---|---|---|---|
| 1,000 individual point lookups | 10.30 | 10.30† | 27.42 | 27.42† | 96.44 | 96.44† | +166.1% |
| Group by username initial | 62.14 | 61.94 | 979.02 | 979.02† | 78.28 | 54.34 | +14.0% |
Milliseconds; lower is faster. † Same API as ordinary.
The point workload performs 1,000 individual lookups. SoloDB's ordinary path is GetById; its compiled path is Compile((q, id) => q.Single(x => x.Id == id)). LiteDB compares FindById with FindOne(Query.EQ("_id", id)). MongoDB compares a normal equality find with an _id_-hinted find. Every requested ID exists.
For aggregation, I much prefer being able to write this:
var groups = solo.Compile(q =>
q.GroupBy(x => x.Username.Substring(0, 1))
.Select(g => new { g.Key, N = g.LongCount() }));
var result = groups().ToDictionary(x => x.Key, x => x.N);
The grouping and counts execute in the database. The C# types and compiler still help with the query.
LiteDB's native equivalent uses its expression language:
var groups = lite.Query()
.GroupBy("SUBSTRING(Username,0,1)")
.Select("{key:@key,n:COUNT(*)}").ToList();
MongoDB receives an aggregation pipeline:
var stage = BsonDocument.Parse(
"{$group:{_id:{$substrCP:['$Username',0,1]},n:{$sum:1}}}");
var groups = mongo.Aggregate<BsonDocument>(new[] { stage }).ToList();
$substrCP selects a substring by Unicode code points; $sum:1 counts the documents in each group. Usernames here are ASCII, giving all three expressions the same meaning. The MongoDB tuned route adds a Username_1 hint. LiteDB retains the working grouping builder.
Atomic insertions
| Operation | SoloDB | LiteDB | MongoDB | Lead over second |
|---|---|---|---|---|
| Insert 200,000 documents atomically | 2,269.75 | 1,597.62 | 1,629.71 | +2.0% |
Milliseconds; lower is faster.
Every insertion sample starts with a fresh database. Secondary indexes are built afterwards. SoloDB uses InsertBatch; LiteDB uses InsertBulk inside BeginTrans/Commit, with rollback on failure.
MongoDB runs as a single-node replica set so the transaction API is available. The timed operation includes the session, transaction and commit:
var options = new TransactionOptions(
readConcern: ReadConcern.Snapshot,
readPreference: ReadPreference.Primary,
writeConcern: WriteConcern.WMajority.With(journal: true));
using var session = client.StartSession();
session.WithTransaction((tx, _) => {
mongo.InsertMany(tx, events);
return true;
}, options);
Individual queries use snapshot read concern. The separate transaction probe checks dirty-read exclusion, rollback, committed changes, stable snapshot reads and fresh subsequent reads.
Updates with a compound predicate
| Operation | SoloDB | LiteDB | MongoDB | Lead over second |
|---|---|---|---|---|
| Native complex-predicate update | 56.98 | 122.53 | 207.17 | +115.0% |
Milliseconds; lower is faster.
The update combines an ID bound, an OR condition, three prefixes and a string-length check:
Expression<Func<Event, bool>> predicate = x =>
x.Id <= 20000
&& (x.System.StartsWith("ui") || x.Type == "Error")
&& x.MachineName.StartsWith("NOD")
&& x.AggId.StartsWith("AB")
&& x.Username.Length > 3;
Each database applies the predicate natively and changes Body to body. The benchmark alternates between "updated-a" and "updated-b" so each invocation changes the matching documents.
SoloDB uses Set to describe the field assignment:
var affected = solo.UpdateMany(
predicate,
x => x.Body.Set(body));
LiteDB takes the update expression first, followed by the predicate. The member initializer updates Body while preserving the other fields:
var affected = lite.UpdateMany(
x => new Event { Body = body },
predicate);
MongoDB uses an update definition inside a transaction, with the snapshot and journaled-majority options shown above:
using var session = client.StartSession();
var affected = session.WithTransaction((tx, _) => {
var result = mongo.UpdateMany(
tx,
Builders<Event>.Filter.Where(predicate),
Builders<Event>.Update.Set(x => x.Body, body));
return result.ModifiedCount;
}, options);
The affected count and the complete resulting document set are checked, including untouched rows.
Index construction and engine settings
| Operation | SoloDB | LiteDB | MongoDB | Lead over second |
|---|---|---|---|---|
| Build common indexes and prepare statistics | 722.79 | 9,016.43 | 735.02 | +1.7% |
Milliseconds; lower is faster.
SoloDB uses WAL and synchronous=FULL, with a 64 MiB cache ceiling. LiteDB uses Direct mode and ordinal collation, preserving empty strings and whitespace. MongoDB uses WiredTiger with a 4 GiB cache. The latter setting was necessary for this full-batch transaction: the attempted 2 GiB run failed with code 388, transaction is too large and will not fit in the storage engine cache. I kept the transaction and raised the cache rather than split the operation into weaker batches.
Can one index help a filter on two fields?
This experiment counts documents where MachineName starts with NOD and AggId starts with AB. Initially, each field has its own index. We then add an index containing both fields and repeat the same count on the same 200,000 documents.
The question is whether the database can use that extra index to do less work. Here are the query times, measured separately from index creation, with three samples before and three after:
| Operation | SoloDB | LiteDB | MongoDB | Lead over second |
|---|---|---|---|---|
| With separate indexes on each field | 38.74 | 321.04 | 83.37 | +115.2% |
| With the additional index | 39.10 | 317.59 | 5.11 | +664.6% |
Milliseconds; lower is faster.
MongoDB benefits from the additional index here. SoloDB stays around 39 ms and LiteDB around 300 ms; neither becomes faster on these separate prefix conditions in this run.
SoloDB: index both fields and compile the count
solo.EnsureIndex(x =>
new ValueTuple<string, string>(x.MachineName, x.AggId));
solo.Optimize();
var count = solo.Compile(
(IQueryable<Event> q, string machine, string aggregate) =>
q.LongCount(x => x.MachineName.StartsWith(machine)
&& x.AggId.StartsWith(aggregate)));
long matches = count("NOD", "AB");
The runner calls Optimize() after each SoloDB index addition, including this one. It then compiles the count again so planning can use the new index information.
LiteDB: index an expression containing both fields
lite.EnsureIndex(
"machine_aggregate",
"{machine:$.MachineName,aggregate:$.AggId}");
var count = lite.Query()
.Where(x => x.MachineName.StartsWith("NOD")
&& x.AggId.StartsWith("AB"))
.Select("{n:COUNT(*._id)}");
long matches = count.Single()["n"].AsInt64;
LiteDB's index stores the result of the two-field expression. The query still filters the fields separately. Creating that index does not make this particular query faster; the measured count and returned result stay unchanged.
MongoDB: create and request the two-field index
mongo.Indexes.CreateOne(new CreateIndexModel<Event>(
Builders<Event>.IndexKeys.Ascending(x => x.MachineName)
.Ascending(x => x.AggId)));
var filter = Builders<Event>.Filter;
long matches = mongo.CountDocuments(
filter.Regex(x => x.MachineName, new BsonRegularExpression("^NOD"))
& filter.Regex(x => x.AggId, new BsonRegularExpression("^AB")),
new CountOptions { Hint = "MachineName_1_AggId_1" });
The hint tells MongoDB to use the index just created. Before adding it, the measured query requests the existing MachineName_1 index.
What does the extra index cost?
An index takes time and space to build. These costs are paid when adding the index, rather than on each count:
| One-time cost | SoloDB | LiteDB | MongoDB | Lead over second |
|---|---|---|---|---|
| Build time, ms | 208.25 | 4,484.47 | 264.93 | +27.2% |
| Reported extra storage | 3.19 MiB | 18.09 MiB | 1.13 MiB | — |
Build time is one measurement per database; SoloDB's includes refreshing query statistics. The storage figures describe the increase reported by each engine: allocated database space for SoloDB, database-file size after pending changes are written for LiteDB, and index storage for MongoDB. The raw reports retain the exact byte counts and query plans.
Files: more than upload and download
SoloDB has a filesystem with directories, metadata, partial reads, offset writes and resizing. An edit looks like an edit:
soloDb.FileSystem.WriteAt("/bench/example.bin", 4096, bytes);
using var file = soloDb.FileSystem.OpenAt("/bench/example.bin");
file.SetLength(8192);
| Read operation | SoloDB ReadAt | SoloDB stream/span | LiteDB stream/array | LiteDB stream/span | MongoDB stream/array | MongoDB stream/span | Lead over second |
|---|---|---|---|---|---|---|---|
| 200 offset reads × 1 KiB | 7.03 | 7.12 | 14.72 | 14.94 | 50.93 | 50.57 | +109.6% |
Milliseconds; lower is faster.
The read workload returns the same 1 KiB at fixed-seed offsets in 200 files. SoloDB compares ReadAt with OpenAt and a span read. LiteDB uses a seekable OpenRead stream. MongoDB has a native seekable download option:
using var file = bucket.OpenDownloadStream(id,
new GridFSDownloadOptions { Seekable = true });
file.Position = offset;
file.ReadExactly(buffer.AsSpan());
The default GridFS download stream is forward-only; requesting Seekable matters. Every returned byte is checked.
Actually editing the files
Here are the edits too, with the replacement code included in the timed operation:
| Operation | SoloDB | LiteDB | MongoDB | Lead over second |
|---|---|---|---|---|
| Edit 100 KiB within a 6.25 MiB file | 1.06 | 31.82 | 25.36 | +2,285.1% |
| Write 100 KiB at offset 100 MiB | 1.08 | 158.70 | 175.89 | +14,618.0% |
| Grow 6.25 MiB to 12.5 MiB | 0.58 | 25.59 | 30.79 | +4,307.4% |
| Truncate 6.25 MiB to 3.125 MiB | 0.75 | 13.59 | 16.11 | +1,721.9% |
Milliseconds; lower is faster.
Every sample starts from the same 6.25 MiB file. Resetting that file happens before the stopwatch starts; checking its complete contents happens after it stops. The range edit begins at 400 KiB. The gap write extends the file to 100 MiB plus 100 KiB and verifies all intervening bytes are zero.
SoloDB edits through WriteAt and resizes through SetLength. The LiteDB and GridFS adapters read the old file, patch or resize a byte array, then upload the replacement. Those reads, allocations and uploads are included. This measures what it costs to deliver the requested bytes through each API; the capability table below explains the guarantees that still differ.
| Operation | SoloDB filesystem | LiteDB FileStorage | MongoDB GridFS |
|---|---|---|---|
| Read from an offset | Native | Native seekable read | Native seekable option |
| Edit a range | Native WriteAt |
Full replacement emulation* | Full replacement emulation* |
| Extend with a zero-filled gap | Native | Allocate and upload zeros* | Allocate and upload zeros* |
| Grow or truncate | Native SetLength |
Resize and upload* | Resize and upload* |
| Directory organization | Native directories | Path-like file IDs | Filenames and metadata |
* The runner verifies the resulting bytes after offset edits, a gap, growth and truncation. LiteDB and MongoDB emulations read the old file, modify a buffer and upload a replacement. They do not gain native sparse allocation or atomic whole-file replacement by doing that. The MongoDB adapter deletes the previous ID before uploading, leaving a replacement gap.
LiteDB documents per-chunk transactions. GridFS does not support multi-document transactions. Those are meaningful differences when the feature you need is an editable file inside your database.
Tags with and without an index
| Operation | SoloDB Ordinary | SoloDB Optimized | LiteDB Ordinary | LiteDB Optimized | MongoDB Ordinary | MongoDB Optimized | Lead over second |
|---|---|---|---|---|---|---|---|
| Unindexed tag count | 101.31 | 101.11 | 332.53 | 312.56 | 69.19 | 69.19† | +46.1% |
| Indexed tag count | 2.98 | 2.63 | 93.26 | 16.35 | 4.98 | 4.98† | +89.1% |
Milliseconds; lower is faster. † Same API as ordinary.
This case is scalar string equality, Tags == "NOK". Both variants first run without a tag index, then both run after adding one. SoloDB recompiles after the schema change. LiteDB's covered variant counts tag values; MongoDB keeps an exact count. Array membership and substring search are different workloads.
JSON round trips: deserialize, then serialize
Orders, customer profiles, products, invoices and activity events each have a compact and a more detailed example. The detailed versions include line items, addresses, product variants, payment records or delivery history. These are fictional application records.
| JSON document, 10,000 round trips | UTF-8 bytes | SoloDB | LiteDB | MongoDB | Lead over second |
|---|---|---|---|---|---|
| Activity login | 285 | 34.76 | 32.18 | 45.09 | +8.0% |
| Activity webhook | 1,285 | 136.28 | 126.45 | 183.55 | +7.8% |
| Customer basic | 210 | 23.16 | 25.16 | 37.46 | +8.6% |
| Customer profile | 1,350 | 136.72 | 127.86 | 183.23 | +6.9% |
| Invoice detailed | 1,785 | 225.72 | 197.50 | 273.85 | +14.3% |
| Invoice simple | 420 | 56.94 | 51.75 | 72.57 | +10.0% |
| Order draft | 333 | 49.99 | 47.14 | 65.78 | +6.1% |
| Order fulfilled | 1,529 | 184.70 | 166.16 | 236.74 | +11.2% |
| Product basic | 353 | 40.11 | 40.59 | 60.50 | +1.2% |
| Product variants | 2,119 | 237.21 | 219.80 | 316.53 | +7.9% |
Milliseconds, median of six rotated samples after warm-up.
Each timed operation deserializes the JSON into a document tree, then serializes it back to a string. SoloDB uses JsonValue.Parse and ToJsonString; LiteDB uses JsonSerializer.Deserialize and Serialize; MongoDB uses BsonDocument.Parse and ToJson with relaxed Extended JSON. The inputs combine strings, numbers, dates, booleans, nulls, nested objects and arrays, including multilingual addresses and product descriptions. Files are read before timing; all parsers receive exactly the same text. Output values are checked before timing and after every sample, allowing native formatting differences. Both deserialization and serialization are warmed, and library order rotates between samples.
MongoDB's call parses JSON into a BSON document tree; it is not a raw BSON decoding benchmark. JSON5-style inputs have their own acceptance table:
| JSON5-style input | SoloDB | LiteDB | MongoDB |
|---|---|---|---|
| unquoted-key | Accepted | Accepted | Accepted |
| trailing-comma | Accepted | Accepted | Accepted |
| comment | Accepted | Rejected | Rejected |
| hexadecimal | Accepted | Rejected | Rejected |
Each accepted input must also produce the expected values.
Compared with the earlier SoloDB/LiteDB article, SoloDB now has a typed query engine, retained read compilation, and the relations and filesystem features introduced in 1.1. This run measures the current packages on the same fixture; it does not recycle old timings from a different machine or workload.
Conclusion
Therefore, based on the current set of evidence, I formally conclude, in an opinionated and biased way, that SoloDB is a Just Works database which is:
- Easy to work with. The query stays in typed LINQ. A repeated query becomes one
Compilewith the changing values as arguments, an update names the field, and editing a file isWriteAtandSetLength. There is no server to start, no BSON pipeline to assemble, no expression string to keep in step with the model, and no file to download, patch and upload. - Low latency and high throughput. The compiled all-document count takes 0.20 ms and a one-prefix count 2.97 ms; 1,000 point lookups take 22.48 ms, a native file edit about 1 ms, and four workers issuing fifty counts each 142.55 ms.
- No obscure errors: it works, or it throws in the debug stage. Queries are translated, planned and bound before they execute, so a shape the engine cannot run fails at
Compileinstead of inside a request. MongoDB's atomic insert died at runtime on a cache limit. An application discovers that in production. SoloDB's failures are the kind the test loop finds first. - Beats commercial grade DBs: In most tests SoloDB came out on top of the contenders.
Complete query results
| Operation | SoloDB Ordinary | SoloDB Optimized | LiteDB Ordinary | LiteDB Optimized | MongoDB Ordinary | MongoDB Optimized | Lead over second |
|---|---|---|---|---|---|---|---|
| Count all documents | 0.37 | 0.20 | 69.44 | 69.44† | 45.07 | 22.40 | +10,890.6% |
| Count one prefix | 3.17 | 2.97 | 67.99 | 18.05 | 5.32 | 5.32† | +79.3% |
| Count two prefixes | 38.83 | 38.56 | 310.64 | 310.64† | 84.22 | 84.22† | +118.4% |
| Count unindexed tag | 101.31 | 101.11 | 332.53 | 312.56 | 69.19 | 69.19† | +46.1% |
| First page | 6.00 | 5.64 | 129.43 | 129.43† | 1.83 | 1.72 | +228.4% |
| Page at offset 5,000 | 14.82 | 14.12 | 134.13 | 134.13† | 34.55 | 24.57 | +74.0% |
| Ten sequential pages | 65.66 | 65.66† | 1,307.29 | 1,307.29† | 31.87 | 31.38 | +109.2% |
| Ten pseudorandom pages | 202.51 | 199.89 | 1,310.24 | 1,310.24† | 1,067.56 | 1,060.72 | +430.7% |
| 1,000 individual point lookups | 10.30 | 10.30† | 27.42 | 27.42† | 96.44 | 96.44† | +166.1% |
| Group by username initial | 62.14 | 61.94 | 979.02 | 979.02† | 78.28 | 54.34 | +14.0% |
| Four workers × 50 exact prefix counts | 145.50 | 142.55 | 4,579.79 | 1,194.75 | 258.67 | 258.67† | +81.5% |
| Indexed tag count | 2.98 | 2.63 | 93.26 | 16.35 | 4.98 | 4.98† | +89.1% |
Milliseconds; lower is faster. † Same API as ordinary.
Bold marks the lowest value for each metric. Managed allocation samples cover the C# benchmark process; MongoDB server and native-engine memory are separate.
Reproduce it
The C# runner, project and methodology are available here. The SoloDB, LiteDB and MongoDB reports contain the timing samples and query plans, with run-specific connection metadata removed. These numbers show what the APIs cost for this data, on this machine; run the benchmark with your own workload to answer the question that matters to your application.