MongoDB Node.js Best Practices for Production (Mongoose)

MongoDB Node.js best practices from production: connection reuse, Mongoose 8 schema validation, indexes, lean queries, pagination and transactions done right.

These MongoDB Node.js best practices come from running Mongoose in production APIs, where the problems are rarely about syntax and almost always about connections, missing indexes and inconsistent data. MongoDB is easy to start with because it accepts anything. That flexibility becomes a problem when bad data piles up and queries that took 5 ms with a thousand documents take 3 seconds with a million.

In this guide I will cover the habits that keep a Mongo database healthy on Node 20 and Mongoose 8: connecting once, validating with schemas, indexing what you query, reading with lean(), paginating efficiently, using transactions when you really need them, and the errors I see most often.

Prerequisites

  • Node.js 20 LTS
  • Mongoose 8 (npm install mongoose@8)
  • MongoDB 6 or newer, locally or on MongoDB Atlas
  • A replica set if you plan to use transactions (Atlas clusters are replica sets by default)

1. Connect to MongoDB once and reuse the connection

Open one connection when the app boots and reuse it everywhere. Mongoose maintains a connection pool internally; every model shares it. Opening a connection per request exhausts the pool and the server's connection limit quickly, and it is the most common cause of mysterious timeouts I see in code reviews.

Connect before you start listening, so the API never accepts traffic it cannot serve, and close the connection cleanly on shutdown.

// src/db.js
import mongoose from 'mongoose'

mongoose.set('strictQuery', true)

export async function connectDb() {
  await mongoose.connect(process.env.MONGO_URL, {
    maxPoolSize: 10,
    serverSelectionTimeoutMS: 5000,
  })
  mongoose.connection.on('error', (err) => console.error('Mongo error', err))
}

// src/server.js
import app from './app.js'
import mongoose from 'mongoose'
import { connectDb } from './db.js'

await connectDb()
const server = app.listen(process.env.PORT || 3000)

process.on('SIGTERM', async () => {
  server.close()
  await mongoose.disconnect()
  process.exit(0)
})

In serverless environments such as Vercel or AWS Lambda, cache the connection promise in a module-level variable so warm invocations reuse it instead of opening a new connection each time.

2. Validate data with Mongoose schemas

Schemas are your contract. Use required, enum, min, max and match so invalid data fails loudly at write time rather than breaking a report six months later. Setters like lowercase and trim normalise values before they are saved, which also makes unique indexes on email actually unique.

// src/models/lead.js
import mongoose from 'mongoose'

const leadSchema = new mongoose.Schema(
  {
    owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
    email: {
      type: String,
      required: true,
      lowercase: true,
      trim: true,
      match: /^\S+@\S+\.\S+$/,
    },
    name: { type: String, required: true, maxlength: 120 },
    score: { type: Number, min: 0, max: 100, default: 0 },
    stage: {
      type: String,
      enum: ['new', 'qualified', 'won', 'lost'],
      default: 'new',
    },
    tags: { type: [String], default: [] },
  },
  { timestamps: true }
)

export default mongoose.model('Lead', leadSchema)

Remember that update queries skip validators by default. Pass runValidators: true to findOneAndUpdate and updateMany, or set it globally with mongoose.set('runValidators', true).

await Lead.findOneAndUpdate(
  { _id: id, owner: userId },
  { $set: { stage: 'won' } },
  { new: true, runValidators: true }
)

3. Index what you query and sort

Every field you filter or sort by frequently needs an index. For compound indexes, follow the ESR rule: Equality fields first, then Sort fields, then Range fields. An index on owner and createdAt serves the typical list my leads, newest first query perfectly.

leadSchema.index({ owner: 1, email: 1 }, { unique: true })
leadSchema.index({ owner: 1, stage: 1, createdAt: -1 })

Use explain() to confirm a query uses an index. If the winning plan shows COLLSCAN, MongoDB is reading every document. Look for IXSCAN, and compare totalDocsExamined with nReturned; ideally they are close.

In production, disable autoIndex and build indexes deliberately during deployment, because building a large index on startup can slow the database for every other request.

const plan = await Lead.find({ owner: userId, stage: 'qualified' })
  .sort({ createdAt: -1 })
  .explain('executionStats')

console.log(plan.queryPlanner.winningPlan)
console.log(plan.executionStats.totalDocsExamined)

// production: build indexes explicitly
mongoose.set('autoIndex', process.env.NODE_ENV !== 'production')
await Lead.syncIndexes() // run in a deploy script

4. Use lean() and projections for reads

lean() returns plain JavaScript objects instead of full Mongoose documents, skipping change tracking, getters and hydration. For read-only API responses it is several times faster and uses far less memory. Combine it with select() so you only fetch the fields you send.

const leads = await Lead.find({ owner: userId, stage: 'qualified' })
  .select('name email score createdAt')
  .sort({ createdAt: -1 })
  .limit(20)
  .lean()

Do not use lean() when you need to call save(), use virtuals or rely on document middleware. For those cases, load the full document.

5. Paginate with cursors, not large skips

skip() is fine for the first few pages, but MongoDB still walks every skipped document, so page 5,000 is slow. For infinite scroll and large exports, use a cursor based on an indexed field: return the last createdAt and _id and ask for documents after it.

export async function listAfter(ownerId, cursor, limit = 20) {
  const filter = { owner: ownerId }
  if (cursor) {
    const { createdAt, id } = JSON.parse(Buffer.from(cursor, 'base64url').toString())
    filter.$or = [
      { createdAt: { $lt: new Date(createdAt) } },
      { createdAt: new Date(createdAt), _id: { $lt: id } },
    ]
  }

  const items = await Lead.find(filter)
    .sort({ createdAt: -1, _id: -1 })
    .limit(limit + 1)
    .lean()

  const hasMore = items.length > limit
  const page = items.slice(0, limit)
  const last = page.at(-1)
  const next = hasMore
    ? Buffer.from(JSON.stringify({ createdAt: last.createdAt, id: last._id })).toString('base64url')
    : null

  return { data: page, next }
}

6. Use transactions only when documents must change together

Single-document writes in MongoDB are already atomic, so a well-designed schema often needs no transactions at all. When you must update several documents together, such as moving credit between two accounts, use a session with withTransaction, which retries transient errors for you. Transactions require a replica set.

const session = await mongoose.startSession()
try {
  await session.withTransaction(async () => {
    await Account.updateOne({ _id: from }, { $inc: { balance: -amount } }, { session })
    await Account.updateOne({ _id: to }, { $inc: { balance: amount } }, { session })
  })
} finally {
  await session.endSession()
}

7. Model data for how you read it

Embed data that is read together and bounded in size, like a lead's tags or an order's line items. Reference data that grows without limit or is shared, like comments or users. Unbounded arrays are a classic trap: documents have a 16 MB limit, and large arrays make every update slower long before you reach it.

Common MongoDB and Mongoose errors and fixes

  • MongooseServerSelectionError: the app cannot reach the server. Check the connection string, network access list on Atlas, and that the database is running.
  • E11000 duplicate key error: a unique index rejected the write. Catch error code 11000 and return a 409 Conflict instead of a 500.
  • CastError: Cast to ObjectId failed: an invalid id came from the URL. Validate with mongoose.isValidObjectId before querying and return 404.
  • Operation buffering timed out after 10000ms: you queried before the connection opened. Await connectDb before starting the server.
  • Unique index not enforced: duplicates already existed when the index was built, so creation failed silently. Clean the data and run syncIndexes.
  • Transaction numbers are only allowed on a replica set member: you are running a standalone server. Use Atlas or start mongod as a single-node replica set.

Security best practices

  • Never pass req.body or req.query straight into a filter; operators like $ne can bypass checks (NoSQL injection). Validate input and keep strictQuery enabled.
  • Scope every query by owner or tenant so users cannot read each other's documents by guessing ids.
  • Create a database user with the least privileges the app needs, not an Atlas admin user.
  • Restrict network access by IP and always use TLS, which Atlas connection strings enable by default.
  • Exclude sensitive fields like password hashes with select: false in the schema.

Conclusion

Most MongoDB performance and data quality problems in Node.js apps trace back to a handful of habits: connect once, validate with schemas, index what you query, read with lean(), paginate with cursors and use transactions sparingly. None of them is complicated, but applying them from the first commit saves painful migrations later. Run explain() on your slowest queries this week; it is usually the quickest win you will find.

Frequently asked questions

Should I open a new MongoDB connection for every request in Node.js?

No. Connect once when the application starts and reuse the connection. Mongoose manages a pool of connections behind the scenes, controlled by maxPoolSize.

What does lean() do in Mongoose?

lean() tells Mongoose to return plain JavaScript objects instead of full documents. It is faster and uses less memory, but the results do not have save(), virtuals or document middleware.

How do I know if a MongoDB query is using an index?

Call explain('executionStats') on the query and look at the winning plan. IXSCAN means an index is used, while COLLSCAN means a full collection scan that needs an index.

Is Mongoose still worth using in 2026?

Yes, for most applications. Schemas, validation and middleware prevent a lot of bad data, and Mongoose 8 has solid TypeScript support. For very high throughput paths you can still drop down to the native driver.

How do I prevent NoSQL injection in MongoDB with Express?

Validate and type-check all input before it reaches a query, and never pass raw request objects as filters. Keeping strictQuery on and using a schema validator such as Zod blocks operator injection like $ne or $gt.