The MongoDB Node.js Driver: CRUD

The MongoDB Node.js driver is the official library that lets a Node.js application talk to a MongoDB server without writing raw wire-protocol messages by hand. It converts your JavaScript objects into BSON, sends them to mongod (or mongos for a sharded cluster), and converts the responses back into JavaScript objects you can use directly. Every CRUD method you already learned in mongoshinsertOne, find, updateOne, deleteMany, and friends — exists on the driver too, just wrapped in Promises so you can await them.

Overview: How the Driver Works

Under the hood, the driver is built around three objects: a MongoClient, a Db, and a Collection. When you call new MongoClient(uri) and then await client.connect(), the driver opens a pool of TCP connections to your cluster — it does not open one connection per query. This connection pool is expensive to create and cheap to reuse, which is why the single most important architectural rule for using the driver is: create one MongoClient for your entire application’s lifetime, not one per request or per function call.

Once connected, client.db("shop") gives you a handle to a database (this does not create it — MongoDB creates databases and collections lazily, on first write), and .collection("users") gives you a handle to a collection. Every CRUD method you call on that collection object serializes your plain JavaScript object into BSON (MongoDB’s binary JSON superset, which adds types like ObjectId, Date, Decimal128, and 64-bit integers that plain JSON doesn’t have), sends it over the wire, and returns a result object once the server acknowledges the operation according to the configured write concern (by default, acknowledged by the primary).

Reads work a little differently from writes. find() does not immediately return documents — it returns a Cursor, a lazy pointer to the result set on the server. Nothing is transferred over the network until you iterate the cursor (with for await, .next(), or, most commonly, .toArray(), which pulls every remaining document into memory as a JS array). This matters for large result sets: calling .toArray() on a query matching millions of documents will try to load all of them into your Node process’s memory at once.

Connecting to MongoDB

Install the driver with npm, then create a client from a connection string. Never hardcode real credentials — use environment variables in production code.

npm install mongodb

Syntax

The core CRUD methods available on a Collection object mirror mongosh exactly, but every one of them is asynchronous and must be awaited:

Method Purpose Returns
insertOne(doc) Insert a single document { acknowledged, insertedId }
insertMany(docs) Insert an array of documents { acknowledged, insertedCount, insertedIds }
findOne(filter, options) Return the first matching document, or null A document or null
find(filter, options) Return a cursor over all matching documents A Cursor (call .toArray())
updateOne(filter, update) Update the first matching document { matchedCount, modifiedCount }
updateMany(filter, update) Update every matching document { matchedCount, modifiedCount }
deleteOne(filter) Delete the first matching document { deletedCount }
deleteMany(filter) Delete every matching document { deletedCount }
findOneAndUpdate(filter, update, options) Update and return the document in one round trip The document (before or after update, per options)

options commonly includes projection (limit returned fields), sort, limit, and for updates, upsert: true (insert if nothing matches).

Examples

Example 1: Connect and insert a document.

import { MongoClient } from "mongodb";

const uri = "mongodb://:@/mydb";
const client = new MongoClient(uri);

async function main() {
  await client.connect();
  const users = client.db("shop").collection("users");

  const result = await users.insertOne({
    name: "Priya Singh",
    email: "priya@example.com",
    age: 29,
    createdAt: new Date()
  });

  console.log(result);
  await client.close();
}

main().catch(console.error);

Output:

{
  acknowledged: true,
  insertedId: ObjectId('66a1f2c3d4e5f60718293a4b')
}

The driver generated an ObjectId for _id automatically because we didn’t supply one. insertedId is that ObjectId, not a plain string — keep that in mind for the next example.

Example 2: Query with a filter, sort, and limit.

import { MongoClient } from "mongodb";

const client = new MongoClient("mongodb://:@/mydb");

async function main() {
  await client.connect();
  const products = client.db("shop").collection("products");

  const cursor = products
    .find({ category: "electronics", inStock: true })
    .sort({ price: -1 })
    .limit(3);

  const results = await cursor.toArray();
  console.log(results);

  await client.close();
}

main().catch(console.error);

Output:

[
  { _id: ObjectId('66a1...'), name: "4K Monitor", category: "electronics", price: 429, inStock: true },
  { _id: ObjectId('66a2...'), name: "Mechanical Keyboard", category: "electronics", price: 149, inStock: true },
  { _id: ObjectId('66a3...'), name: "USB-C Hub", category: "electronics", price: 39, inStock: true }
]

find() builds the query and returns a cursor immediately; the actual network round trip happens on .toArray(). .sort({ price: -1 }) and .limit(3) are pushed down to the server, so MongoDB only sends back the three most expensive matching documents instead of the whole collection.

Example 3: Update and delete on an orders collection.

import { MongoClient, ObjectId } from "mongodb";

const client = new MongoClient("mongodb://:@/mydb");

async function main() {
  await client.connect();
  const orders = client.db("shop").collection("orders");

  // Mark a single order as shipped
  const updateResult = await orders.updateOne(
    { _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") },
    { $set: { status: "shipped", shippedAt: new Date() } }
  );
  console.log(updateResult);

  // Cancel every still-pending order for one customer
  const updateManyResult = await orders.updateMany(
    { customerId: "cust_1029", status: "pending" },
    { $set: { status: "cancelled" } }
  );
  console.log(updateManyResult);

  // Remove a single test order
  const deleteResult = await orders.deleteOne({
    _id: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d2")
  });
  console.log(deleteResult);

  await client.close();
}

main().catch(console.error);

Output:

{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
{ acknowledged: true, matchedCount: 4, modifiedCount: 4 }
{ acknowledged: true, deletedCount: 1 }

Notice we build the filter with new ObjectId("..."), not the raw string — the driver stores _id as a true ObjectId BSON type, and a string will never equal it.

How It Works Step by Step

  • Connect: client.connect() resolves the connection string (including SRV records for Atlas), authenticates, and opens a pool of sockets to the replica set members.
  • Serialize: when you call insertOne(doc), the driver’s BSON library walks your JS object and encodes it into BSON bytes, converting JS Date to BSON UTC datetime, JS numbers to BSON double/int32/int64 as appropriate, and so on.
  • Send & route: the command is sent to the primary (for writes) or, depending on read preference, a primary or secondary (for reads) over an existing pooled connection — no new TCP handshake per call.
  • Server executes: for a find, the query planner checks for a usable index (IXSCAN) or falls back to scanning the whole collection (COLLSCAN); for a write, the storage engine applies the change and, once the configured write concern is satisfied, acknowledges it.
  • Deserialize: the response BSON is decoded back into a plain JS object (or array of objects, for a cursor batch) and handed to you through the resolved Promise.

Common Mistakes

Mistake 1: Comparing a route-param string to _id without converting it.

const id = req.params.id; // e.g. "64f1a2b3c4d5e6f7a8b9c0d1", a plain string
const user = await users.findOne({ _id: id }); // never matches — _id is stored as ObjectId, not a string

Fix it by wrapping the string in ObjectId before querying:

import { MongoClient, ObjectId } from "mongodb";

const client = new MongoClient("mongodb://:@/mydb");

async function findUserById(id) {
  await client.connect();
  const users = client.db("shop").collection("users");
  return users.findOne({ _id: new ObjectId(id) });
}

Mistake 2: Using updateOne when you meant to update every match.

// Only the FIRST pending order is updated — the rest silently stay "pending"
await orders.updateOne({ status: "pending" }, { $set: { status: "processing" } });

If the intent is to affect every matching document, use updateMany:

await orders.updateMany({ status: "pending" }, { $set: { status: "processing" } });

Mistake 3: Creating a new MongoClient for every request.

app.get("/users", async (req, res) => {
  const client = new MongoClient(uri); // opens a brand-new connection pool on every request
  await client.connect();
  const users = await client.db("shop").collection("users").find().toArray();
  res.json(users);
  await client.close();
});

This exhausts connections under load and adds a full handshake to every request’s latency. Create the client once, at application startup, and reuse it:

const client = new MongoClient(uri);
await client.connect();

app.get("/users", async (req, res) => {
  const users = await client.db("shop").collection("users").find().toArray();
  res.json(users);
});

Best Practices

  • Create a single MongoClient instance when your app starts and reuse it for the lifetime of the process; let the driver manage pooling internally.
  • Read the connection string from an environment variable, never hardcode credentials in source code.
  • Always wrap driver calls in try/catch (or a .catch() on your top-level main()) so connection or query errors don’t crash the process silently.
  • Use a projection on find/findOne to return only the fields you need, especially on documents with large embedded arrays.
  • Convert route-param or query-string IDs to ObjectId before using them in a filter, and validate the string first (a malformed 24-character check) to avoid a thrown error crashing a request handler.
  • Close the client (await client.close()) only on graceful application shutdown, not after each operation.
  • Prefer updateOne/deleteOne only when you truly mean “just the first match” — default to asking whether updateMany/deleteMany is what the feature actually needs.

Practice Exercises

  • Write a script that connects to a store database, inserts three documents into a products collection, then finds and prints every product priced above a value you choose, sorted by price descending.
  • Write an update that uses $inc to increment a views counter by 1 on a single document, looked up by its ObjectId converted from a string.
  • Write a deleteMany call that removes every document from an orders collection where status is "cancelled" and createdAt is older than 30 days (hint: build a Date 30 days in the past and use $lt).

Summary

  • The Node.js driver mirrors mongosh’s CRUD methods exactly, but every call returns a Promise you must await.
  • find() returns a lazy cursor; nothing crosses the network until you iterate it or call .toArray().
  • Create one MongoClient per application and reuse its connection pool — never open a new client per request.
  • _id is stored as a BSON ObjectId; convert incoming strings with new ObjectId(idString) before querying by it.
  • Choose updateOne/deleteOne versus updateMany/deleteMany deliberately — picking the wrong one silently leaves matching documents untouched.