Node.js with MongoDB

MongoDB is a document database that stores data as JSON-like objects called BSON documents, grouped into collections instead of rows and tables. Node.js talks to MongoDB through the official mongodb npm package, a driver that opens a TCP connection to the database and exposes async methods for every operation: connecting, inserting, querying, updating, and deleting. Because MongoDB documents map almost directly onto JavaScript objects, the two pair unusually well — there is no object-relational mismatch to manage. This lesson covers the driver in depth: how it connects, how to perform full CRUD, how connection pooling works under the hood, and the mistakes that trip up most beginners.

Overview: How Node.js Talks to MongoDB

You do not talk to MongoDB directly from Node — you install the mongodb package, which implements the MongoDB Wire Protocol over a plain TCP socket. When you call client.connect(), the driver opens one or more sockets to the server (or servers, in a replica set) and keeps them open in a connection pool. Every subsequent operation (insertOne, find, updateOne, and so on) reuses a socket from that pool instead of opening a new connection each time, which is why you create a MongoClient once and share it across your whole application rather than per request.

Because the driver communicates over a network socket, all of this I/O is handled by libuv’s event-driven layer (the OS-level socket polling — epoll on Linux, kqueue on macOS, IOCP on Windows), not by the fixed-size libuv thread pool that handles filesystem and DNS lookups. That means a MongoDB query does not block the event loop and does not consume one of the four default thread-pool workers; it simply registers a callback that fires when the socket has data, exactly like a network request in http. This is also why a single Node.js process can have thousands of MongoDB operations in flight at once without spawning thousands of threads.

Every read and write happens against a specific database and collection, obtained with client.db(name) and db.collection(name). Both calls are synchronous and cheap — they just return a handle; no network round trip happens until you call an actual operation like insertOne or find.

Syntax

The general shape of using the driver is: create a client, connect once, get a collection handle, run operations, close the client when the whole program is done (not after every operation).

const client = new MongoClient(uri, options);
await client.connect();
const collection = client.db(dbName).collection(collectionName);
// ... run operations on collection ...
await client.close(); // only when the app is shutting down
Method Description Returns
new MongoClient(uri, options) Creates a client bound to a connection string; does not connect yet MongoClient
client.connect() Opens the connection pool to the server(s) Promise<MongoClient>
client.db(name) Gets a handle to a database Db
db.collection(name) Gets a handle to a collection Collection
collection.insertOne/insertMany(doc) Inserts one or many documents Promise<InsertResult>
collection.find(filter) Returns a lazy cursor over matching documents FindCursor
collection.findOne(filter) Returns the first matching document or null Promise<Document|null>
collection.updateOne/updateMany(filter, update) Applies update operators ($set, $inc, …) to matching docs Promise<UpdateResult>
collection.deleteOne/deleteMany(filter) Removes matching documents Promise<DeleteResult>
client.close() Closes every socket in the pool Promise<void>

The connection string (URI) follows the form mongodb://[user:pass@]host[:port]/[database][?options] for a standalone server, or mongodb+srv://user:pass@cluster.example.mongodb.net/db for MongoDB Atlas, which resolves the real hosts via DNS SRV records. Install the driver first:

npm install mongodb

Examples

Example 1: Connecting and Inserting Documents

import { MongoClient } from "mongodb";

const uri = process.env.MONGODB_URI ?? "mongodb://localhost:27017";
const client = new MongoClient(uri);

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

    const result = await products.insertMany([
      { name: "Keyboard", price: 49.99, inStock: true },
      { name: "Mouse", price: 19.99, inStock: true },
      { name: "Monitor", price: 199.99, inStock: false },
    ]);

    console.log(`Inserted ${result.insertedCount} documents`);
  } catch (err) {
    console.error("Database error:", err.message);
  } finally {
    await client.close();
  }
}

main();

Output:

Inserted 3 documents

This connects once, gets a handle to the products collection inside the shop database (both are created automatically on first write if they don’t exist), and inserts three documents in a single round trip with insertMany. The try/finally guarantees the pool is closed even if the insert throws.

Example 2: Querying with Filters, Projection, and Sort

import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI ?? "mongodb://localhost:27017");

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

    const cursor = products
      .find({ inStock: true })
      .project({ _id: 0, name: 1, price: 1 })
      .sort({ price: -1 });

    for await (const doc of cursor) {
      console.log(doc);
    }
  } finally {
    await client.close();
  }
}

main();

Output:

{ name: 'Keyboard', price: 49.99 }
{ name: 'Mouse', price: 19.99 }

find() does not fetch anything by itself — it returns a FindCursor that only queries the server as you iterate it (or call .toArray()). Here .project() asks the server to return only the name and price fields (dropping _id), and .sort({ price: -1 }) orders results highest price first. Iterating with for await...of pulls documents in batches instead of materializing the whole result set in memory — the right choice for large collections.

Example 3: Updating and Deleting with ObjectId

import { MongoClient, ObjectId } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI ?? "mongodb://localhost:27017");

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

    const mouse = await products.findOne({ name: "Mouse" });
    if (!mouse) throw new Error("Mouse not found");

    const updateResult = await products.updateOne(
      { _id: new ObjectId(mouse._id) },
      { $set: { price: 17.99 } }
    );
    console.log(`Matched ${updateResult.matchedCount}, modified ${updateResult.modifiedCount}`);

    const deleteResult = await products.deleteOne({ name: "Monitor" });
    console.log(`Deleted ${deleteResult.deletedCount} document(s)`);
  } finally {
    await client.close();
  }
}

main();

Output:

Matched 1, modified 1
Deleted 1 document(s)

Every document MongoDB creates gets a unique _id of type ObjectId unless you supply your own. When you already have a document (as returned by findOne), its _id is already an ObjectId instance, so wrapping it again is safe. The update uses the $set operator to change only the price field, leaving the rest of the document untouched.

Under the Hood: What Happens on Each Call

Tracing await products.updateOne(filter, update) end to end: (1) the driver serializes your filter and update objects to BSON; (2) it grabs a socket from the connection pool (or waits for one to free up if the pool is saturated at maxPoolSize, default 100); (3) it writes the wire-protocol command to that socket — a non-blocking write registered with the event loop; (4) your async function suspends at the await and control returns to the event loop, which is free to process other work (other requests, timers, other queries); (5) when the server responds, the OS marks the socket readable, libuv’s poll phase picks that up, and the driver resolves the pending promise with the parsed UpdateResult; (6) your async function resumes on the next microtask tick with updateResult in hand. Nothing in this chain blocks the thread that is running your Node.js process, which is why one process can serve many concurrent database operations from many concurrent HTTP requests.

Common Mistakes

Mistake 1: Creating a new MongoClient per request

http.createServer(async (req, res) => {
  const client = new MongoClient(uri);
  await client.connect();
  const doc = await client.db("shop").collection("products").findOne({ name: "Mouse" });
  res.end(JSON.stringify(doc));
  await client.close();
});

This opens and tears down a whole TCP connection (and the MongoDB handshake) on every single request, which is slow and will exhaust available connections under load. Create the client once, outside any request handler, and reuse it:

async function getMouse(products) {
  return products.findOne({ name: "Mouse" });
}

Connect once at startup, keep the same client/collection references in module scope, and pass them into your handlers. The driver’s internal pool already gives you safe concurrent reuse.

Mistake 2: Querying with a raw string instead of ObjectId

const doc = await products.findOne({ _id: req.params.id });
// silently returns null — req.params.id is a string, _id is stored as ObjectId

MongoDB stores _id as a BSON ObjectId, not a string, so comparing it to a plain string never matches and findOne quietly returns null instead of throwing. Validate and convert explicitly:

import { ObjectId } from "mongodb";

async function getById(products, id) {
  if (!ObjectId.isValid(id)) return null;
  return products.findOne({ _id: new ObjectId(id) });
}

Mistake 3: Loading an entire large collection with .toArray()

const all = await products.find({}).toArray();
for (const p of all) {
  process(p);
}

For a collection with millions of documents, .toArray() buffers every single one in Node’s memory before you can process the first, which can exhaust the process’s heap. Stream through the cursor instead, so documents are processed as they arrive in batches:

const cursor = products.find({});
for await (const p of cursor) {
  process(p);
}

Best Practices

  • Create a single MongoClient for the lifetime of your process and reuse it — never open one per request.
  • Keep the connection string in an environment variable (via dotenv or node --env-file=.env), never hardcoded in source.
  • Use .project() to fetch only the fields you actually need, reducing network and memory overhead.
  • Create indexes with collection.createIndex() on fields you filter or sort by often; without one, MongoDB scans every document.
  • Iterate cursors with for await...of instead of .toArray() for large result sets.
  • Always validate a client-supplied id with ObjectId.isValid() before constructing an ObjectId from it.
  • Wrap every database call in try/catch (or let a route-level error handler catch it) — a rejected promise you don’t handle crashes the process.
  • Use a ClientSession and transactions when multiple writes across documents must succeed or fail together.
  • Call client.close() only on graceful shutdown (e.g. on SIGTERM), not after individual operations.

Practice Exercises

  • Write a script that connects to a local MongoDB instance, creates a users collection, inserts three user documents (name and email fields), and prints how many were inserted.
  • Write an async function getUserById(collection, id) that returns null immediately if id fails ObjectId.isValid(), otherwise queries by _id and returns the document or null if not found. It should never throw for a bad id.
  • Extend Example 2’s query to paginate results using .skip() and .limit(): print page 2 of a 2-item-per-page listing of in-stock products sorted by price descending.

Summary

  • The mongodb package is the official driver; install it with npm install mongodb and connect with a single shared MongoClient.
  • MongoDB operations run over plain TCP sockets handled by libuv’s event-driven I/O, not the fixed thread pool used for filesystem work — they never block the event loop.
  • find() returns a lazy cursor; nothing is fetched from the server until you iterate it or call .toArray().
  • _id fields are ObjectId instances, not strings — convert and validate client-supplied ids before querying.
  • Reuse one client and its connection pool for the whole app’s lifetime; never open a new connection per request.
  • Stream large result sets with for await...of instead of loading everything into memory with .toArray().