Drivers and ODMs in application code
Wire the official driver correctly with a single client per process, know what an ODM adds and hides, and handle the errors that actually occur in production.
The official driver
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI, {
maxPoolSize: 20, // per process, not per request
minPoolSize: 2,
maxIdleTimeMS: 60000,
serverSelectionTimeoutMS: 5000,
writeConcern: { w: "majority" },
});
await client.connect();
const orders = client.db("shop").collection("orders");
const doc = await orders.findOne({ _id: id }, { projection: { total: 1, status: 1 } });- Create one
MongoClientper process and share it. It owns the connection pool and the topology monitor, so a client per request throws away pooling and multiplies heartbeat traffic. - The driver retries retryable reads and single-statement writes once after a network error. A multi-statement sequence is your responsibility, not the driver's.
- The driver validates nothing about field types. If you want a runtime guarantee, use a validator on the collection or a schema layer in the application.
serverSelectionTimeoutMSis what stops a request from hanging forever when no member is reachable; set it and handle the error.
ODMs and schema layers
const orderSchema = new Schema({
customer: { type: Schema.Types.ObjectId, ref: "Customer", required: true },
status: { type: String, enum: ["new", "paid", "shipped"], default: "new" },
total: { type: Schema.Types.Decimal128, min: 0 },
placedAt: { type: Date, default: Date.now },
}, { timestamps: true, strict: "throw" });
orderSchema.index({ customer: 1, placedAt: -1 });
const Order = model("Order", orderSchema);
const doc = await Order.findById(id).lean(); // plain object, not hydrated| Reach for the driver | Reach for an ODM |
|---|---|
| You need exact control over the query sent | You want casting and defaults for free |
| Aggregation pipelines and bulk operations | You want middleware for validation and hooks |
| Jobs, scripts and data pipelines | A team that benefits from one model layer |
| Latency-sensitive read paths | Rapid prototyping |
💡
An ODM schema is enforced by one application, not by the database. A second service, a migration script or an aggregation writing with
$merge can store any shape it likes. Add a $jsonSchema validator to the collection when the shape really has to hold.Errors, retries and shutdown
try {
await orders.updateOne(
{ _id: id, version: 3 },
{ $set: { status: "shipped" }, $inc: { version: 1 } }
);
} catch (err) {
if (err.code === 112) return retryOptimistic(); // WriteConflict
if (err.code === 11000) return handleDuplicate(err); // duplicate key
if (err.hasErrorLabel("RetryableWriteError")) return retry();
throw err;
}
process.on("SIGTERM", async () => {
await client.close();
process.exit(0);
});- A duplicate key error (11000) is often a deliberate signal: a unique index enforcing idempotency on a natural key.
- A write conflict inside a transaction is transient. Retry the entire transaction, not the individual statement that failed.
- Optimistic concurrency with a version field avoids holding a lock between the read and the write, and turns a lost update into a detectable conflict.
- Close the client on shutdown so the driver can stop its monitoring threads and let in-flight operations finish.
FAQ
Should I use an ODM from the start?
For a product with a stable domain model and a team that values guard rails, yes. For a pipeline, a script or any path where the exact query shape matters, the driver is less to learn and less to work around.
How should a request handle a connection failure?
Set
serverSelectionTimeoutMS so the request fails quickly instead of hanging, retry only operations the driver marks retryable, and return an error the caller can act on rather than swallowing it and returning an empty result.Related
Transactions, sessions and read/write concerns Data modelling: embedding vs referencing and schema validation
Last refreshed 2026-09-18.