Type something to search...
Connecting Node.js to MongoDB with the Official Driver

Connecting Node.js to MongoDB with the Official Driver

Most Node.js tutorials get you connected to MongoDB in five lines and then leave you on your own. That's fine for a demo, but those five lines usually hide the decisions that matter in production: how many clients you create, how long a request waits when the database is unreachable, how you shut down cleanly, and what happens when an insert hits a duplicate key.

The official MongoDB Node.js driver (the mongodb package on npm) handles connection pooling, server discovery, retries, and BSON serialization for you. You don't need an ODM to use it well. You just need to understand a handful of concepts and set it up once, correctly.

This guide covers installing the driver, writing a connection module you can reuse across your app, the core CRUD methods, working with cursors and ObjectId, TypeScript support, error handling, and the configuration options worth knowing about. The examples use version 6.x of the driver with ES modules.

Installing the Driver

Start with a Node.js project and install the package:

mkdir notes-api && cd notes-api
npm init -y
npm install mongodb

Add "type": "module" to package.json so you can use import syntax and top-level await:

{
  "name": "notes-api",
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env src/index.js"
  },
  "dependencies": {
    "mongodb": "^6.0.0"
  }
}

The --env-file flag is built into recent Node.js versions, so you don't need the dotenv package just to load a .env file.

Understanding the Connection String

The driver connects using a connection string (also called a URI). There are two formats:

# Standard format: list hosts explicitly
MONGODB_URI="mongodb://localhost:27017/notes"

# SRV format: used by Atlas, hosts discovered via DNS
MONGODB_URI="mongodb+srv://app_user:s3cret@cluster0.abcd1.mongodb.net/notes?retryWrites=true&w=majority&appName=notes-api"

The mongodb+srv:// form looks up the cluster's hosts through a DNS SRV record, so you don't have to list every replica set member. It also enables TLS by default. The path segment (/notes) sets the default database, and query parameters configure options like write concern and the app name that shows up in server logs.

If your password contains special characters like @, :, or /, it must be percent-encoded, or the URI parser will misread it. encodeURIComponent(password) does the job.

Store the URI in an environment variable, never in source code:

# .env
MONGODB_URI="mongodb://localhost:27017"
MONGODB_DB="notes"

Your First Connection

Here's the smallest meaningful program: connect, ping the server, and disconnect.

// src/ping.js
import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI);

try {
  await client.connect();
  const result = await client.db("admin").command({ ping: 1 });
  console.log("Connected:", result);
} finally {
  await client.close();
}
$ node --env-file=.env src/ping.js
Connected: { ok: 1 }

Creating a MongoClient doesn't open any sockets. Calling connect() does the initial server discovery. Strictly speaking, connect() is optional in modern drivers because the first operation connects automatically, but calling it explicitly at startup is useful: your app fails fast with a clear error instead of failing on the first user request.

One Client for the Whole App

The single most important rule: create one MongoClient per process and reuse it. A MongoClient isn't a connection. It's a manager that holds a pool of connections to every server in your deployment, monitors their health, and routes operations. Creating a new client per request means a new pool, new TLS handshakes, and new authentication every time, which is slow and can exhaust the server's connection limit.

Put the client in its own module:

// src/db.js
import { MongoClient } from "mongodb";

const uri = process.env.MONGODB_URI;
if (!uri) {
  throw new Error("MONGODB_URI is not set");
}

export const client = new MongoClient(uri, {
  appName: "notes-api",
  maxPoolSize: 20,
  serverSelectionTimeoutMS: 5000,
});

export const db = client.db(process.env.MONGODB_DB ?? "notes");

export async function connectToDatabase() {
  await client.connect();
  await db.command({ ping: 1 });
  console.log(`MongoDB connected (db: ${db.databaseName})`);
}

ES modules are cached after their first import, so every file that does import { db } from "./db.js" gets the same client. Then, in your entry point:

// src/index.js
import { connectToDatabase, client } from "./db.js";
import { startServer } from "./server.js";

await connectToDatabase();
const server = startServer();

async function shutdown(signal) {
  console.log(`${signal} received, shutting down`);
  server.close();
  await client.close();
  process.exit(0);
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

Closing the client on SIGTERM lets in-flight operations finish and releases connections cleanly, which matters in containers where orchestrators send SIGTERM before killing the process.

Databases and Collections

client.db(name) and db.collection(name) are cheap, synchronous calls. They return handles without touching the network, so you can call them wherever it's convenient. Databases and collections are created implicitly on the first write.

import { db } from "./db.js";

const notes = db.collection("notes");
const users = db.collection("users");

CRUD Operations

The driver's method names map directly to the operations you'd use in mongosh. If you want a deeper tour of the operations themselves, see MongoDB CRUD Operations Explained with Practical Examples.

Inserting Documents

const result = await notes.insertOne({
  title: "Buy coffee",
  body: "Whole beans, medium roast",
  tags: ["errands"],
  done: false,
  createdAt: new Date(),
});

console.log(result.insertedId);
// ObjectId('66f8c2a1e4b0a1b2c3d4e5f6')

await notes.insertMany([
  {
    title: "Call the bank",
    tags: ["admin"],
    done: false,
    createdAt: new Date(),
  },
  {
    title: "Book flights",
    tags: ["travel"],
    done: true,
    createdAt: new Date(),
  },
]);

If you don't provide an _id, the driver generates an ObjectId on the client before sending the document.

Reading Documents

findOne() returns a document or null. find() returns a cursor, which you consume with toArray() or iterate.

const note = await notes.findOne({ title: "Buy coffee" });

const openNotes = await notes
  .find({ done: false }, { projection: { title: 1, tags: 1 } })
  .sort({ createdAt: -1 })
  .limit(20)
  .toArray();
[
  {
    _id: ObjectId("66f8c2b9e4b0a1b2c3d4e5f7"),
    title: "Call the bank",
    tags: ["admin"],
  },
  {
    _id: ObjectId("66f8c2a1e4b0a1b2c3d4e5f6"),
    title: "Buy coffee",
    tags: ["errands"],
  },
];

Updating Documents

const { matchedCount, modifiedCount } = await notes.updateOne(
  { title: "Buy coffee" },
  { $set: { done: true, completedAt: new Date() } },
);

await notes.updateMany({ tags: "travel" }, { $addToSet: { tags: "2026" } });

When you need the updated document back, use findOneAndUpdate. In driver 6.x it returns the document directly (or null), not a wrapper object:

const updated = await notes.findOneAndUpdate(
  { title: "Call the bank" },
  { $set: { done: true } },
  { returnDocument: "after" },
);

console.log(updated.done); // true

This is a common stumbling block when following older tutorials, which read result.value. If you really need the metadata, pass includeResultMetadata: true.

Deleting Documents

const { deletedCount } = await notes.deleteOne({ title: "Book flights" });
await notes.deleteMany({
  done: true,
  completedAt: { $lt: new Date("2026-01-01") },
});

Counting

const openCount = await notes.countDocuments({ done: false });
const approxTotal = await notes.estimatedDocumentCount();

countDocuments runs an accurate count for a filter. estimatedDocumentCount reads collection metadata, which is instant but ignores filters. The old cursor count() method is deprecated, so avoid it.

Working with ObjectId

The most frequent bug in Node.js MongoDB code is querying _id with a string. An ObjectId and its hex string are different types, so this matches nothing:

// Wrong: returns null
await notes.findOne({ _id: "66f8c2a1e4b0a1b2c3d4e5f6" });

Convert first, and validate before converting, because the constructor throws on invalid input:

import { ObjectId } from "mongodb";

export function parseId(value) {
  return typeof value === "string" && /^[0-9a-f]{24}$/i.test(value)
    ? new ObjectId(value)
    : null;
}

const id = parseId(req.params.id);
if (!id) {
  return res.status(400).json({ error: "Invalid id" });
}
const note = await notes.findOne({ _id: id });

A strict 24-character hex check is more predictable than ObjectId.isValid(), which has historically accepted some inputs (like certain 12-character strings) that aren't what you'd expect from a URL parameter. When sending documents to a browser, JSON.stringify turns an ObjectId into its hex string automatically.

Iterating Large Result Sets

toArray() loads every result into memory. For exports or batch jobs over many documents, iterate the cursor instead, which fetches results in batches:

const cursor = notes.find({ done: true }).batchSize(500);

for await (const note of cursor) {
  await archive(note);
}

The for await loop closes the cursor automatically when it finishes or when you break out of it.

Aggregations

aggregate() takes a pipeline array and returns a cursor, just like find():

const tagCounts = await notes
  .aggregate([
    { $unwind: "$tags" },
    { $group: { _id: "$tags", count: { $sum: 1 } } },
    { $sort: { count: -1 } },
  ])
  .toArray();
[
  { _id: "errands", count: 4 },
  { _id: "admin", count: 2 },
  { _id: "travel", count: 1 },
];

Using TypeScript

The driver ships its own type definitions. Pass a document type to collection() and you get typed filters, updates, and results:

import { MongoClient, ObjectId } from "mongodb";

interface Note {
  _id?: ObjectId;
  title: string;
  body?: string;
  tags: string[];
  done: boolean;
  createdAt: Date;
}

const client = new MongoClient(process.env.MONGODB_URI!);
const notes = client.db("notes").collection<Note>("notes");

const note = await notes.findOne({ done: false });
// note is WithId<Note> | null

await notes.updateOne({ _id: note!._id }, { $set: { done: "yes" } });
// Type error: 'string' is not assignable to 'boolean'

The types are a compile-time aid only. They don't validate data coming back from the database, so pair them with server-side schema validation or a runtime validator if data integrity matters.

Handling Errors

Errors the server returns arrive as MongoServerError with a numeric code. The one you'll handle most often is 11000, a duplicate key violation on a unique index:

import { MongoServerError } from "mongodb";

await users.createIndex({ email: 1 }, { unique: true });

try {
  await users.insertOne({ email: "ada@example.com", name: "Ada" });
} catch (err) {
  if (err instanceof MongoServerError && err.code === 11000) {
    console.log("Email already registered:", err.keyValue);
    // Email already registered: { email: 'ada@example.com' }
  } else {
    throw err;
  }
}

Connectivity problems surface differently. If no suitable server is reachable within serverSelectionTimeoutMS, the operation throws a MongoServerSelectionError. The default timeout is 30 seconds, which is a long time for a web request to hang, so lowering it to a few seconds (as in the db.js module above) gives users a fast error instead.

Transient network errors on writes are handled for you by retryable writes, which are on by default. A write interrupted by a failover is retried once automatically.

Configuration Options Worth Knowing

You can set options in the connection string or in the MongoClient constructor. These are the ones that matter most:

OptionDefaultWhat it controls
maxPoolSize100Max connections per server in the pool
minPoolSize0Connections kept open when idle
serverSelectionTimeoutMS30000How long an operation waits to find a usable server
connectTimeoutMS30000Timeout for establishing a single TCP connection
appNamenoneLabel shown in server logs and currentOp
retryWritestrueAutomatic single retry for eligible writes
readPreferenceprimaryWhich replica set members serve reads
wmajorityWrite concern (acknowledgement level)

For most apps, the defaults are sensible. Tune maxPoolSize down if you run many app instances against a small cluster, since the total connection count is roughly instances times pool size times servers.

Stable API

If you connect to Atlas, you can pin the Stable API version so server upgrades don't change the behavior of commands your app depends on:

import { MongoClient, ServerApiVersion } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI, {
  serverApi: {
    version: ServerApiVersion.v1,
    strict: true,
    deprecationErrors: true,
  },
});

With strict: true, commands outside the Stable API are rejected, which surfaces compatibility problems early rather than after an upgrade.

Common Mistakes

Creating a client per request. This is the classic cause of "too many connections" errors and slow endpoints. Create the client once at module level and import it.

Querying _id with a string. Convert to ObjectId first, and validate input before converting.

Reading result.value from findOneAndUpdate. That was the pre-6.0 return shape. The document now comes back directly.

Calling toArray() on unbounded queries. Always pair find() with a limit() in request handlers, and iterate cursors for batch work.

Leaving the 30-second server selection timeout in place. If the database goes down, every request hangs for half a minute before failing. Lower it so errors are fast and visible.

Hardcoding credentials. Keep the URI in environment variables or a secrets manager, and use a database user with only the roles your app needs.

Conclusion

Connecting Node.js to MongoDB properly takes a little more than five lines, but not much more. Create one MongoClient, configure sensible timeouts and an app name, connect at startup, and close on shutdown. From there, the driver's insertOne, find, updateOne, and aggregate methods mirror what you already know from the shell, with ObjectId conversion and MongoServerError codes being the two details that trip people up most.

Your next step: take the db.js module from this guide, drop it into an existing project, and replace any place that creates its own client with an import from it. It's a small refactor that eliminates a whole category of connection problems.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading