Native Node.js Driver
The official mongodb npm package is the driver every ODM (including Mongoose) is built on top of. Using it directly gives you the thinnest possible layer between your code and the wire protocol — full control, minimal overhead, and no imposed schema.
MongoClient and Connection Pooling
import { MongoClient } from 'mongodb'
const client = new MongoClient('mongodb://localhost:27017', {
maxPoolSize: 20, // max concurrent connections in the pool
minPoolSize: 5,
serverSelectionTimeoutMS: 5000
})
await client.connect()
const db = client.db('shop')
const users = db.collection('users')
// One MongoClient instance per process — it manages a connection pool
// internally, so create it once and reuse it across requests.MongoClient per request. Instantiate it once at application start and share the pooled client — that pool is what makes concurrent requests fast.CRUD Methods
// Create
await users.insertOne({ email: 'alice@example.com', name: 'Alice' })
await users.insertMany([{ name: 'Bob' }, { name: 'Carol' }])
// Read
const alice = await users.findOne({ email: 'alice@example.com' })
const admins = await users.find({ role: 'admin' }).toArray()
// Update
await users.updateOne({ _id: alice._id }, { $set: { name: 'Alice S.' } })
await users.updateMany({ role: 'user' }, { $set: { active: true } })
// Delete
await users.deleteOne({ _id: alice._id })
await users.deleteMany({ active: false })Cursors with Async Iteration
find() returns a cursor, not an array — use async iteration to stream results without loading everything into memory at once.
const cursor = users.find({ role: 'user' }).project({ email: 1, name: 1 })
for await (const doc of cursor) {
console.log(doc.email)
}
// Or materialize when the result set is known to be small
const all = await users.find({ role: 'admin' }).toArray()Transactions
Transactions require a replica set or sharded cluster (not a standalone mongod). Use a session and wrap the operations in withTransaction, which handles retries on transient errors automatically.
const session = client.startSession()
try {
await session.withTransaction(async () => {
await db.collection('accounts').updateOne(
{ _id: fromId }, { $inc: { balance: -amount } }, { session }
)
await db.collection('accounts').updateOne(
{ _id: toId }, { $inc: { balance: amount } }, { session }
)
}, {
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' }
})
} finally {
await session.endSession()
}Error Handling
import { MongoServerError } from 'mongodb'
try {
await users.insertOne({ email: 'alice@example.com' })
} catch (err) {
if (err instanceof MongoServerError && err.code === 11000) {
console.error('Duplicate email')
} else {
throw err
}
}Connection String Options
Option | Purpose |
|---|---|
| Maximum connections held open per client (default 100) |
| Connections kept warm even when idle |
| Automatically retries a write once on a transient network/failover error |
| Default write concern — wait for acknowledgment from a majority of replica set members |
| primary, primaryPreferred, secondary, secondaryPreferred, nearest |
| Database the credentials are validated against (often |
| Enable TLS for the connection (required by Atlas) |
mongodb+srv://appUser:pass@cluster0.mongodb.net/shop?retryWrites=true&w=majority&authSource=admin
Mongoose vs Native Driver
Native driver: minimal overhead, full control, no imposed schema — best for high-throughput services or libraries.
Mongoose: schemas, validation, middleware, and populate() at the cost of a small hydration/casting overhead.
Many teams use Mongoose for typical CRUD app code and drop to the native driver (accessible via
mongoose.connection.db) for performance-critical aggregation pipelines.
await client.close()) so connections are released cleanly — leaving them open can exhaust the server's connection limit under repeated redeploys.