Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 038 / 100

MongoDB with Node.js: a practical MongoDB & NoSQL guide

Use the official driver well.

Expona Admin9 min read

Introduction

After 37 days exploring MongoDB fundamentals and NoSQL patterns, it's time to put theory into practice. MongoDB with Node.js: a practical MongoDB & NoSQL guide bridges the gap between understanding data modeling concepts and shipping production code that scales. We'll focus on using the official MongoDB Node.js driver correctly—not just making it work, but making it work well under load, with proper error handling, and with confidence that your connection strategy won't become a bottleneck.

Getting the driver basics right early saves us from performance surprises, connection leaks, and cascading failures in production. Let's walk through four critical patterns that separate solid MongoDB applications from ones that creak under pressure.

One Client — Create One MongoClient and Reuse It

The most common mistake we see is creating a new MongoClient for each database operation. It feels intuitive—connect, query, disconnect—but in a Node.js application handling concurrent requests, this approach creates a new TCP connection and authentication handshake for every single operation. That's expensive, and it's wrong.

The MongoDB driver is designed with connection pooling in mind. A single MongoClient instance manages a pool of connections internally and reuses them across your application. Creating the client is the expensive operation; using it is cheap.

Here's the pattern we follow:

javascript
// Initialize once at application startup
const { MongoClient } = require('mongodb');

let mongoClient;

async function getMongoClient() {
  if (mongoClient) {
    return mongoClient;
  }
  
  mongoClient = new MongoClient(process.env.MONGODB_URI, {
    maxPoolSize: 50,
    minPoolSize: 10,
    serverSelectionTimeoutMS: 5000,
  });
  
  await mongoClient.connect();
  console.log('MongoDB client connected');
  
  return mongoClient;
}

// In your Express app or server startup
const app = require('express')();

app.listen(3000, async () => {
  await getMongoClient();
  console.log('Server running');
});

// Graceful shutdown
process.on('SIGTERM', async () => {
  if (mongoClient) {
    await mongoClient.close();
  }
  process.exit(0);
});

For larger applications, we often create a dedicated database module:

javascript
// db.js
const { MongoClient } = require('mongodb');

let cachedClient = null;

async function connectToDatabase() {
  if (cachedClient) {
    return cachedClient;
  }

  const client = new MongoClient(process.env.MONGODB_URI);
  await client.connect();
  cachedClient = client;
  
  return client;
}

module.exports = { connectToDatabase };

Then import and use it everywhere:

javascript
// userService.js
const { connectToDatabase } = require('./db');

async function findUserById(userId) {
  const client = await connectToDatabase();
  const db = client.db('myapp');
  const users = db.collection('users');
  
  return users.findOne({ _id: ObjectId(userId) });
}

module.exports = { findUserById };

One client, reused across all operations. The driver handles queuing requests and managing connections internally. You get the benefits of connection pooling, reduced latency, and lower CPU overhead—all from this single architectural decision.

Typed Collections — Use TypeScript Generics for Document Types

MongoDB is schemaless, but our application code isn't. We work with structured data: users have names, emails, and roles. Orders have items, totals, and statuses. TypeScript generics let us bring type safety to MongoDB operations without imposing a schema on the database.

The MongoDB driver's `Collection` type accepts a generic parameter representing your document shape:

javascript
// types.ts
export interface User {
  _id?: ObjectId;
  email: string;
  name: string;
  role: 'admin' | 'user' | 'guest';
  createdAt: Date;
  metadata?: Record;
}

export interface Order {
  _id?: ObjectId;
  userId: ObjectId;
  items: Array<{
    productId: ObjectId;
    quantity: number;
    price: number;
  }>;
  total: number;
  status: 'pending' | 'shipped' | 'delivered';
  createdAt: Date;
}

Now use these types with your collections:

javascript
// userRepository.ts
import { Collection, ObjectId } from 'mongodb';
import { User } from './types';
import { connectToDatabase } from './db';

async function getUsersCollection(): Promise> {
  const client = await connectToDatabase();
  const db = client.db('myapp');
  return db.collection('users');
}

async function findUserByEmail(email: string): Promise {
  const users = await getUsersCollection();
  return users.findOne({ email });
}

async function createUser(user: Omit): Promise {
  const users = await getUsersCollection();
  const result = await users.insertOne({
    ...user,
    createdAt: new Date(),
  });
  
  return {
    ...user,
    _id: result.insertedId,
    createdAt: new Date(),
  };
}

async function updateUserRole(userId: ObjectId, role: User['role']): Promise {
  const users = await getUsersCollection();
  await users.updateOne(
    { _id: userId },
    { $set: { role } }
  );
}

export { findUserByEmail, createUser, updateUserRole };

TypeScript now catches mismatches at compile time. If you try to set `role` to an invalid string, the type checker flags it. If you try to access a property that doesn't exist on the document, you'll know before the code runs. This is especially valuable in a schemaless database where runtime errors from malformed data can be hard to trace.

For complex queries, we can type the filter objects too:

javascript
// Typed filter—TypeScript ensures we only filter on valid fields
const filter: Filter = { email: 'test@example.com', role: 'admin' };
const user = await users.findOne(filter);

Connection Pooling — maxPoolSize Controls Concurrent Connections

The `maxPoolSize` option tells the driver how many connections to maintain in its pool. In a high-concurrency environment—a busy SaaS app, an e-commerce platform during sales, or a real-time trading system—this becomes critical.

The default `maxPoolSize` is 100, which works for many applications. But we tune it based on our workload:

javascript
const mongoClient = new MongoClient(process.env.MONGODB_URI, {
  maxPoolSize: 50,        // Max 50 concurrent connections
  minPoolSize: 10,        // Keep 10 warm at idle
  maxIdleTimeMS: 60000,   // Close idle connections after 1 minute
  waitQueueTimeoutMS: 10000, // Timeout if waiting for a connection > 10s
  serverSelectionTimeoutMS: 5000,
});

For a typical Node.js REST API with moderate traffic, `maxPoolSize: 50` is reasonable. For high-frequency background jobs or microservices that burst traffic, consider 75–100. For lightweight APIs or cron jobs that rarely hit the database, 20–30 may be enough.

The key insight: each pooled connection is a resource. Too low, and concurrent requests queue up waiting for an available connection, tanking latency. Too high, and you waste memory and TCP connections, potentially hitting OS limits or MongoDB server limits. We monitor connection pool usage in production and adjust based on actual traffic patterns.

Check pool status programmatically if needed:

javascript
const client = await connectToDatabase();
const serverStatus = await client.db('admin').command({ serverStatus: 1 });
// Inspect connection pool metrics in the response

Handle Errors — Retryable Writes Are On By Default

The MongoDB driver comes with automatic retry logic for transient failures. If a network hiccup or brief server blip causes an operation to fail, the driver retries it automatically. This is enabled by default and saves us from writing custom retry logic for every operation.

But we still need to handle errors explicitly, because not all failures are retryable. Here's a robust error-handling pattern:

javascript
// orderService.ts
import { MongoError } from 'mongodb';

async function processOrder(userId: ObjectId, items: any[]) {
  try {
    const client = await connectToDatabase();
    const db = client.db('myapp');
    const orders = db.collection('orders');
    
    const order = {
      userId,
      items,
      total: items.reduce((sum, item) => sum + item.price * item.quantity, 0),
      status: 'pending',
      createdAt: new Date(),
    };
    
    const result = await orders.insertOne(order);
    return { success: true, orderId: result.insertedId };
    
  } catch (error) {
    if (error instanceof MongoError) {
      // Duplicate key error—order already exists
      if (error.code === 11000) {
        return { success: false, error: 'Order already exists' };
      }
      
      // Write concern error—couldn't replicate to majority
      if (error.hasErrorLabel('WriteConcernError')) {
        console.error('Write concern failed, retrying...', error);
        // Implement custom retry or circuit breaker here
        throw new Error('Database write failed after retries');
      }
      
      // Other MongoDB errors
      console.error('Database error:', error.message);
      throw error;
    }
    
    // Non-MongoDB errors (network, etc.)
    console.error('Unexpected error:', error);
    throw error;
  }
}

For operations that must succeed, implement an exponential backoff retry:

javascript
async function withRetry(
  operation: () => Promise,
  maxRetries = 3,
  delayMs = 100
): Promise {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      
      const delay = delayMs * Math.pow(2, attempt - 1);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
const result = await withRetry(() => orders.insertOne(orderDoc));

Retryable writes are on by default, but idempotency matters. For critical operations like payments or inventory updates, ensure your logic is idempotent or use MongoDB transactions if coordinating multiple collections.

Common Mistakes

Creating a new MongoClient per request: This bypasses connection pooling and hammers your database with new connections. Stick with a singleton client.

Not setting maxPoolSize appropriately: The default works, but production traffic patterns often demand tuning. Monitor and adjust based on actual concurrency.

Ignoring write concern: By default, writes are acknowledged (`w: 1`). For critical data, consider `w: 'majority'` to ensure replication, understanding the latency trade-off.

Assuming all errors are retryable: Authentication errors, validation errors, and duplicate key errors are not retryable. Implement conditional retry logic.

Not closing the client on shutdown: Leaving connections open can cause unexpected behavior during deployments. Always close gracefully.

Key Takeaways

  • Create a single MongoClient instance at startup and reuse it across your entire application—connection pooling is built in and invisible.
  • Use TypeScript generics to type your collections and documents; the driver supports this natively and catches bugs at compile time.
  • Tune `maxPoolSize` based on your workload; monitor connection usage in production and adjust as traffic patterns evolve.
  • Handle errors explicitly; retryable writes are on by default, but distinguish between transient failures and permanent errors like duplicate keys.
  • Implement graceful shutdown to close the client and drain connections when your application terminates.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles