When MongoDB is the right call
MongoDB shines when your data is document-shaped, your schema evolves quickly, and you want to store nested objects without a pile of joins. It's a poor fit when your data is highly relational with lots of cross-entity transactions — that's Postgres territory. Pick it deliberately, not by habit.
This guide takes you from a provisioned managed MongoDB to a connected, indexed, production-ready setup using the Node native driver and Mongoose.
The connection string
mongodb://USER:PASSWORD@HOST:27017/DBNAME?authSource=admin
mongodb+srv://USER:PASSWORD@CLUSTER/DBNAME # SRV form, common with clustersRead it from MONGODB_URI in the environment. On PandaStack, provisioning a managed MongoDB injects the connection string into your service automatically.
Connect once, reuse the client
The number-one MongoDB-in-Node mistake is connecting on every request. The driver manages an internal connection pool; create one client at startup and reuse it.
// db.js (native driver)
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI, {
maxPoolSize: 20,
minPoolSize: 2,
serverSelectionTimeoutMS: 5000,
});
let db;
export async function getDb() {
if (!db) {
await client.connect();
db = client.db(); // db name from the URI
}
return db;
}With Mongoose:
import mongoose from "mongoose";
await mongoose.connect(process.env.MONGODB_URI, {
maxPoolSize: 20,
serverSelectionTimeoutMS: 5000,
});Call connect once at boot, not inside a request handler.
Define a schema (yes, even with Mongo)
"Schemaless" doesn't mean "no schema" — it means the database won't enforce one, so *you* should. Mongoose gives you validation:
import mongoose from "mongoose";
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true, lowercase: true },
name: { type: String, required: true },
roles: { type: [String], default: ["member"] },
createdAt: { type: Date, default: Date.now },
}, { timestamps: true });
export const User = mongoose.model("User", userSchema);If you use the native driver, enforce shape in your code or with MongoDB's JSON Schema validation on the collection.
Indexes are not optional
The most common MongoDB performance disaster is a collection scan on a field you query constantly. Create indexes for every field you filter or sort on:
// native driver
await db.collection("users").createIndex({ email: 1 }, { unique: true });
await db.collection("orders").createIndex({ userId: 1, createdAt: -1 });// mongoose — declare on the schema
userSchema.index({ email: 1 }, { unique: true });Use a compound index when you filter on one field and sort on another (like { userId: 1, createdAt: -1 } above). Verify with explain():
const plan = await db.collection("orders")
.find({ userId: id }).sort({ createdAt: -1 }).explain("executionStats");
// look for IXSCAN, not COLLSCANSeeing COLLSCAN means you're missing an index.
Schema design: embed vs. reference
- Embed when data is accessed together and the sub-document is bounded (e.g., a user's address). One read, no join.
- Reference when the related data is large, grows unboundedly, or is shared across documents (e.g., a user's orders — could be thousands).
The anti-pattern is an ever-growing embedded array (the "unbounded array" problem) — a document has a 16MB limit and large arrays kill performance. Reference instead.
CRUD basics
const users = db.collection("users");
await users.insertOne({ email: "a@b.com", name: "Ada" });
const u = await users.findOne({ email: "a@b.com" });
await users.updateOne({ _id: u._id }, { $set: { name: "Ada L." } });
await users.deleteOne({ _id: u._id });Use $set for partial updates — passing a whole document replaces it and silently drops fields.
Transactions (when you need them)
MongoDB supports multi-document transactions on replica sets:
const session = client.startSession();
try {
await session.withTransaction(async () => {
await accounts.updateOne({ _id: from }, { $inc: { balance: -amt } }, { session });
await accounts.updateOne({ _id: to }, { $inc: { balance: amt } }, { session });
});
} finally {
await session.endSession();
}Reach for transactions only when you truly need atomicity across documents — they're heavier than single-document operations, which are already atomic.
Deploying on PandaStack
- 1Provision a managed MongoDB from the dashboard.
- 2Attach it to your service — the connection string is injected.
- 3Create indexes as part of your release/migration step.
- 4Deploy and verify connection in the live logs.
- 5Confirm scheduled backups are enabled (PandaStack supports scheduled + manual backups).
Checklist
- One client, reused; pool sized under any connection cap.
- Indexes on every queried/sorted field; verify with
explain(). - Schema validation in code or on the collection.
- No unbounded embedded arrays — reference instead.
- Backups enabled and restore tested.
References
- MongoDB Node driver: https://www.mongodb.com/docs/drivers/node/current/
- Mongoose docs: https://mongoosejs.com/docs/guide.html
- MongoDB indexing strategies: https://www.mongodb.com/docs/manual/applications/indexes/
- Data modeling (embed vs reference): https://www.mongodb.com/docs/manual/data-modeling/
- MongoDB transactions: https://www.mongodb.com/docs/manual/core/transactions/
---
Want a managed MongoDB with the connection string wired into your app and backups handled? PandaStack provisions it in a click. Start free at https://dashboard.pandastack.io