MongoDB

Beta: not ready for production use

The driver works and is tested, but it has not yet met the bar we set for production: MongoDB's full conformance suites, and the connection pool under sustained load. Try it and report what breaks; keep production workloads on a setup you can fall back to until it is marked stable.

Status

Working today

Area
ServersMongoDB 7.0 and 8.x: one server or a replica set, with discovery, monitoring, failover and read preferences
Connection stringsmongodb:// and mongodb+srv:// (SRV and TXT lookups, TLS on by default)
SecurityTLS with verified certificates, SCRAM-SHA-256 and SCRAM-SHA-1, --allow-net scoping
OperationsCRUD, cursors, aggregation, bulk writes, indexes, cancellation with AbortSignal
ConsistencySessions, causal consistency, transactions, retried reads and writes
BSONEvery type, int64 as bigint, passing MongoDB's BSON corpus
ConformanceMongoDB's discovery and monitoring, server selection, max-staleness and seedlist suites

In the pipeline

Area
ConformanceMongoDB's CRUD, transactions, retryable and sessions suites (covered by our own live tests today), and the connection-pool suite
LoadA soak test and failover under load for the connection pool, and a published benchmark
Pool optionsmaxIdleTimeMS, minPoolSize, waitQueueTimeoutMS, socketTimeoutMS: a connection string with one is refused today
FeaturesChange streams, sharded clusters (mongos), load-balanced mode, wire compression, client-level bulkWrite, GridFS
AuthenticationX.509 client certificates, then AWS IAM and OIDC

Client-side field-level and Queryable Encryption are not planned: they depend on MongoDB's C library, and esrun links no native database code.

Getting started

mongodb is built into runtime:db: there is nothing to install.

JavaScript
import { connect, mongodb } from "runtime:db";

await using client = await connect("mongodb://localhost:27017/app", { driver: mongodb });
const users = client.db().collection("users");

await users.insertOne({ name: "Ada", joined: new Date() });
const ada = await users.findOne({ name: "Ada" });

The methods are the ones the official MongoDB driver has, so its documentation and examples carry over: insertOne, find, updateMany, aggregate, bulkWrite, createIndex. Every option and result is on the MongoDB reference.

It connects to MongoDB 7.0 or later: one server or a replica set, named directly or through mongodb+srv://. Sharded clusters and change streams are not supported yet.

Permissions

Connecting needs --allow-net for the server's host and port:

Shell
esrun --allow-net=db.internal:27017 app.js

Credentials and TLS

Credentials go in the URL, percent-encoded, or in the options:

JavaScript
const client = await connect("mongodb://db.internal:27017/app", {
  driver: mongodb,
  auth: { username: "app", password: env.MONGO_PASSWORD },
  tls: true,
});

The authentication mechanism is negotiated: SCRAM-SHA-256 when the server offers it, SCRAM-SHA-1 otherwise. The user is looked up in the URL's database, or admin; set authSource when it is defined elsewhere.

TLS is verified. A server with a certificate from a private authority needs that authority trusted, with tlsCAFile (which also needs --allow-read for the file) or ca:

JavaScript
const client = await connect("mongodb://db.internal:27017/app?tlsCAFile=certs/ca.pem", {
  driver: mongodb,
});
Certificate checks cannot be turned off

tlsInsecure and tlsAllowInvalidCertificates are refused. Trust the authority that signed the certificate instead.

Documents and types

A document is a plain object, and its values are BSON: _id is a BSON.ObjectId, a 64-bit integer reads as a bigint, and a date as a Date.

JavaScript
import { BSON } from "runtime:serialization";

const user = await users.findOne({ _id: new BSON.ObjectId(id) });
await users.updateOne({ _id: user._id }, { $inc: { visits: 1 } });

How each JavaScript value is stored is in the BSON type mapping. Two cases are worth knowing. A count the server keeps as a 64-bit integer arrives as a bigint (5n). A whole number written from JavaScript is stored as a 32-bit integer; wrap it in BSON.Double when it must be a double.

insertOne gives a document without an _id a new ObjectId, and returns it as insertedId. It inserts a copy: the object you passed is not changed.

Reading many documents

find and aggregate return a cursor. Nothing is sent until you read it, so sort, limit, skip and project chain first:

JavaScript
for await (const user of users.find({ active: true }).sort({ joined: -1 }).limit(50)) {
  console.log(user.name);
}

const totals = await orders
  .aggregate([{ $group: { _id: "$customer", total: { $sum: "$amount" } } }])
  .toArray();

Results arrive a batch at a time. Leaving a for await early releases the cursor on the server; a cursor read with next() should be closed with close() or await using.

Stopping an operation

Every operation takes signal and timeoutMS:

JavaScript
const recent = await users
  .find({ active: true }, { signal: request.signal, timeoutMS: 2_000 })
  .toArray();

A stopped operation rejects with the signal's reason, or ERR_DB_TIMEOUT, and the client stays usable. timeoutMS in the connection string applies to every operation that does not give its own.

Errors

A failure is a DbError with a portable code and the server's own number on backendCode:

JavaScript
try {
  await users.insertOne({ email });
} catch (e) {
  if (e.code === "ERR_DB_UNIQUE_VIOLATION") return conflict(e.server.keyValue);
  throw e;
}

e.server carries the server's codeName and errorLabels. A failed write in insertMany or bulkWrite also carries every writeErrors entry and the result of what was written before it.

Sessions

Operations run in sessions without your asking. Start one yourself when several operations belong together: a read in it sees the session's earlier writes.

JavaScript
await using session = client.startSession();
await orders.insertOne(order, { session });
const placed = await orders.findOne({ _id: order._id }, { session });

Replica sets

List one or more members and name the set; the client finds the rest and follows elections:

JavaScript
const client = await connect(
  "mongodb://db1.internal,db2.internal/app?replicaSet=rs0",
  { driver: mongodb },
);

Writes go to the primary. Reads do too, unless you say otherwise, for the whole client or for one read:

JavaScript
const reports = await connect(url, { driver: mongodb, readPreference: "secondaryPreferred" });

const nearby = await orders
  .find({ day }, { readPreference: { mode: "nearest", tags: [{ dc: "eu" }] } })
  .toArray();

Atlas and other SRV connection strings

A mongodb+srv:// string, as MongoDB Atlas gives out, works as it is. TLS is on by default. DNS supplies the members, so allow the lookup's host and each member it returns:

JavaScript
const client = await connect(
  "mongodb+srv://app:secret@cluster0.abcde.mongodb.net/shop",
  { driver: mongodb },
);
Shell
esrun --allow-net=cluster0.abcde.mongodb.net,ac-1.abcde.mongodb.net:27017,ac-2.abcde.mongodb.net:27017,ac-3.abcde.mongodb.net:27017 app.js

A bare --allow-net covers all of them.

A read from a secondary may not see the latest writes; maxStalenessSeconds keeps reads off secondaries that have fallen behind.

During an election there is briefly no primary. Writes wait for the new one, up to serverSelectionTimeoutMS (30 seconds by default), and then proceed. A write caught by the step-down itself is retried once on the new primary, and the server applies it only once; so is a read whose connection drops. See retries for which operations qualify. To reach one member and nothing else, use directConnection=true.

Every member must be reachable under the name the set's configuration gives it, and allowed by --allow-net.

Transactions

On a replica set, client.transaction runs a function in a transaction and commits it: every write in it is kept, or none is.

JavaScript
await client.transaction(async (tx) => {
  const accounts = tx.db().collection("accounts");
  await accounts.updateOne({ _id: from }, { $inc: { balance: -amount } });
  await accounts.updateOne({ _id: to }, { $inc: { balance: amount } });
});

Inside the function, tx is the client seen through the transaction: its databases and collections run in it without being passed a session. Throwing abandons the transaction. When the server reports a conflict, or the commit's outcome is unknown, the transaction or the commit is retried for up to two minutes, so the function may run more than once: keep anything that must not happen twice, like sending an email, outside it.

A standalone server has no transactions, and transaction says so. For finer control, sessions have startTransaction, commitTransaction and abortTransaction.

Pool size

A client keeps a pool of connections to each server, 100 at most by default. Set maxPoolSize in the URL or the options, or pool: { max } in connect(). Operations past that wait for a connection; signal and timeoutMS stop the wait too. An idle client keeps nothing running, so a program that has finished exits without close().

Last updated on
Edit this page