MongoDB Schema Design Best Practices for Node.js Applications
MongoDB schema design best practices for Node.js: embedding vs referencing, design patterns, Mongoose with TypeScript, indexes, validation and pagination.
By Harsh Thummar 12–15 min read

MongoDB is often described as "schemaless", and that one word causes more production problems than almost anything else in a Node.js backend.
Your collections may not enforce a schema by default, but your application always has one. The only question is whether you design it on purpose or discover it later through slow queries, bloated documents and inconsistent data.
Good MongoDB schema design starts from a simple idea: model your data around how your application reads and writes it, not around how a relational diagram would look.
In this guide, we will cover embedding vs referencing, the schema design patterns MongoDB recommends, a worked example of a personal finance app, Mongoose schemas in TypeScript, indexes, $jsonSchema validation, transactions, pagination, aggregation reports and the mistakes I see most often.
Start With Access Patterns, Not Entities
In SQL, you normalise first and write queries later. In MongoDB, you do it the other way around.
Before creating a single collection, write down the questions your application asks the database:
1. Show a user's accounts with current balances (every dashboard load)
2. List a user's transactions for one account, newest first (very frequent, paginated)
3. Show this month's spending per category vs budget (frequent)
4. Record a new transaction (frequent write)
5. Transfer money between two accounts (occasional, must be atomic)
6. Export a yearly report (rare)For each query, note how often it runs, how much data it returns and whether it is a read or a write. The frequent operations should be cheap, ideally a single indexed query against a single collection.
That list becomes your design brief. Every embedding, reference and index decision below is justified by one of those lines.
Embedding vs Referencing
The central decision in MongoDB data modeling is whether related data lives inside a document (embedding) or in another collection linked by an _id (referencing).
Embed when data is read together and bounded
Embedding gives you everything in one read and makes single-document writes atomic.
{
"_id": "user_1",
"email": "harsh@example.com",
"settings": { "currency": "INR", "weekStartsOn": 1 },
"addresses": [
{ "label": "Home", "city": "Surat" },
{ "label": "Office", "city": "Ahmedabad" }
]
}Settings and addresses are always loaded with the user and will never grow into the thousands. That is a perfect fit.
Reference when data grows without limit or is shared
Reference when the related side is large, grows continuously, is accessed on its own or is shared between many parents.
A practical rule by relationship size
- One-to-few (a user's addresses): embed an array.
- One-to-many (a user's accounts, a blog post's comments): embed if the list is bounded and small, otherwise store the parent's
_idon each child. - One-to-squillions (an account's transactions, log events): always store the parent reference on the child. Never keep an ever-growing array on the parent.
- Many-to-many (users and shared budgets): store an array of references on the side that is queried most, or use a small linking collection when both sides are large.
Unbounded arrays and the 16MB limit
A single BSON document in MongoDB has a maximum size of 16 MB. Long before you hit that limit, large arrays already hurt: every read pulls the whole document, every update rewrites more data and indexes on the array (multikey indexes) grow with each element.
MongoDB's own documentation lists unbounded arrays as a schema anti-pattern. If an array can grow with user activity, it belongs in its own collection.
MongoDB Schema Design Patterns With Examples
MongoDB documents a set of reusable patterns. These five cover most of what a typical Node.js application needs.
1. Subset pattern
Keep a small, frequently used slice of related data embedded and the full set in its own collection.
For example, an account document stores its last five transactions for the dashboard, while the full history lives in transactions:
db.accounts.updateOne(
{ _id: accountId },
{
$push: {
recentTransactions: {
$each: [{ _id: txId, amount: -45000, category: "Food", date: new Date() }],
$sort: { date: -1 },
$slice: 5,
},
},
}
);$slice keeps the array bounded, so it can never become an unbounded array.
2. Extended reference pattern
Copy a few rarely changing fields from the referenced document to avoid a $lookup on every read.
{
"_id": "tx_1",
"userId": "user_1",
"account": { "_id": "acc_1", "name": "HDFC Savings", "currency": "INR" },
"amount": -45000,
"category": "Food"
}The trade-off is duplication. Only copy fields that rarely change, and decide upfront how you will update them (or whether stale copies are acceptable) when the source changes.
3. Computed pattern
If a value is read far more often than the data behind it changes, compute it on write instead of on every read.
An account's balance is the classic example. Instead of summing all transactions on every page load, update it with $inc whenever a transaction is recorded.
4. Bucket pattern
Group many small, related items into one document per time window or per fixed count. Monthly summaries are a good example: one document per user per month with totals per category.
For high-volume measurements (sensor readings, metrics), MongoDB's time series collections apply bucketing internally, so prefer them over a hand-rolled bucket for that kind of data.
5. Outlier pattern
Design for the typical case and handle rare large documents separately. If 99% of shared budgets have a handful of members but a few have hundreds, keep members embedded and set a flag such as hasOverflow: true when the list exceeds a threshold, moving the extra members to an overflow collection. Normal documents stay fast and simple.
Worked Example: A Personal Finance App
Let's apply this to a real-world shape. In the HSFin case study, a React + Node.js + MongoDB personal finance dashboard, the core entities are users, accounts, transactions and budgets, and every record is scoped to a user.
Mapping the access patterns from earlier:
- users: one document per user, with settings embedded (one-to-one, always read together).
- accounts: separate collection with
userId. A user has a handful of accounts, but accounts are updated independently and carry a computedbalance. - transactions: separate collection with
userIdandaccountId. This is the one-to-squillions side and must never be an array on the account. - budgets: one document per user, category and month, with a computed
spentfield.
Money is stored as an integer in minor units (paise or cents). Floating point numbers cannot represent many decimal values exactly, and integers keep sums and comparisons exact. Decimal128 is the other valid option if you need arbitrary decimal precision.
Mongoose Schemas in TypeScript
Here is the transaction model. Mongoose can infer the TypeScript type from the schema, so you do not maintain two definitions. For the wider project setup, see TypeScript with React and Node.js.
// models/Transaction.ts
import { Schema, model, InferSchemaType, Types } from "mongoose";
const transactionSchema = new Schema(
{
userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
accountId: { type: Schema.Types.ObjectId, ref: "Account", required: true },
type: { type: String, enum: ["income", "expense", "transfer"], required: true },
amount: {
type: Number,
required: true,
validate: {
validator: Number.isInteger,
message: "amount must be an integer in minor units",
},
},
category: { type: String, required: true, trim: true, maxlength: 40 },
note: { type: String, trim: true, maxlength: 280 },
date: { type: Date, required: true },
},
{ timestamps: true } // adds createdAt and updatedAt
);
transactionSchema.index({ userId: 1, accountId: 1, date: -1, _id: -1 });
transactionSchema.index({ userId: 1, category: 1, date: -1 });
export type Transaction = InferSchemaType<typeof transactionSchema> & {
_id: Types.ObjectId;
};
export const TransactionModel = model("Transaction", transactionSchema);And the budget model, with a unique compound index so a user cannot create two budgets for the same category and month:
// models/Budget.ts
import { Schema, model } from "mongoose";
const budgetSchema = new Schema(
{
userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
category: { type: String, required: true },
month: { type: String, required: true, match: /^\d{4}-\d{2}$/ }, // "2026-10"
limit: { type: Number, required: true, min: 0 },
spent: { type: Number, default: 0 }, // computed pattern
},
{ timestamps: true }
);
budgetSchema.index({ userId: 1, category: 1, month: 1 }, { unique: true });
export const BudgetModel = model("Budget", budgetSchema);Use lean() for read-only queries
By default Mongoose wraps every result in a full document with change tracking, getters and methods. For API responses you usually only need plain objects:
const items = await TransactionModel.find({ userId, accountId })
.sort({ date: -1, _id: -1 })
.limit(20)
.lean<Transaction[]>();lean() returns plain JavaScript objects, which are lighter and faster to serialise. Skip it when you need document methods such as save(), virtuals or getters.
Always scope queries by userId taken from the authenticated session, never from the request body. The secure REST API guide covers that authentication layer in detail.
Indexing for Your Queries
Indexes are part of schema design, not an afterthought. A well-shaped document with the wrong indexes still produces collection scans.
Compound index order: the ESR guideline
MongoDB recommends ordering compound index fields by Equality, Sort, Range:
- Fields matched with exact equality first.
- Then fields used in the sort.
- Then fields filtered by a range (
$gt,$lt,$gte,$lte).
For the query "a user's Food expenses over 5,000, newest first":
db.transactions
.find({ userId: uid, category: "Food", amount: { $lt: -500000 } })
.sort({ date: -1 });
// ESR: equality (userId, category) → sort (date) → range (amount)
db.transactions.createIndex({ userId: 1, category: 1, date: -1, amount: 1 });Putting the range field before the sort field often forces an in-memory sort, which gets slower as the result set grows.
Unique and TTL indexes
- Unique indexes enforce business rules at the database level, like the one-budget-per-month rule above or a unique email on users.
- TTL indexes delete documents automatically after a time. They are ideal for password-reset tokens, email verification codes and short-lived sessions.
const resetTokenSchema = new Schema({
userId: { type: Schema.Types.ObjectId, required: true },
tokenHash: { type: String, required: true },
expiresAt: { type: Date, required: true },
});
// Delete each document once its expiresAt time has passed
resetTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });TTL deletion runs as a periodic background task, so expired documents can linger briefly. Always check expiresAt in your code as well.
Verify with explain()
Never guess whether an index is used. Ask MongoDB:
db.transactions
.find({ userId: uid, accountId: aid })
.sort({ date: -1 })
.limit(20)
.explain("executionStats");Look for an IXSCAN stage instead of COLLSCAN, and compare totalKeysExamined and totalDocsExamined with nReturned. When those numbers are close, the index fits the query well.
Database-Level Validation With $jsonSchema
Mongoose validation only protects writes that go through Mongoose. Scripts, migrations and other services can bypass it. MongoDB's built-in schema validation is a second line of defence:
db.createCollection("transactions", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["userId", "accountId", "type", "amount", "category", "date"],
properties: {
userId: { bsonType: "objectId" },
accountId: { bsonType: "objectId" },
type: { enum: ["income", "expense", "transfer"] },
amount: { bsonType: ["int", "long"] },
category: { bsonType: "string", maxLength: 40 },
date: { bsonType: "date" },
},
},
},
validationLevel: "strict",
validationAction: "error",
});For an existing collection, apply the same validator with the collMod command. Use validationAction: "warn" first if you need to find invalid legacy documents before enforcing the rule.
Note: JavaScript numbers are stored asdoubleby default. If you enforce an integerbsonTypelike the example above, cast amounts withInt32orLongin your driver code, or allow"double"and rely on application-level integer checks.
Transactions: Only When You Need Them
A write to a single document is always atomic in MongoDB. That is the biggest argument for embedding data that changes together.
Multi-document transactions are available on replica sets and sharded clusters (including MongoDB Atlas). Use them when one business operation must update several documents together, such as a transfer between two accounts:
import mongoose, { Types } from "mongoose";
import { AccountModel } from "./models/Account";
import { TransactionModel } from "./models/Transaction";
export async function transfer(
userId: Types.ObjectId,
fromId: Types.ObjectId,
toId: Types.ObjectId,
amount: number
) {
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
const from = await AccountModel.findOneAndUpdate(
{ _id: fromId, userId, balance: { $gte: amount } },
{ $inc: { balance: -amount } },
{ session, new: true }
);
if (!from) throw new Error("Insufficient funds or account not found");
await AccountModel.updateOne(
{ _id: toId, userId },
{ $inc: { balance: amount } },
{ session }
);
const date = new Date();
await TransactionModel.create(
[
{ userId, accountId: fromId, type: "transfer", amount: -amount, category: "Transfer", date },
{ userId, accountId: toId, type: "transfer", amount, category: "Transfer", date },
],
{ session }
);
});
} finally {
await session.endSession();
}
}withTransaction() commits on success, aborts if the callback throws and retries on transient errors. Pass the session to every operation inside, otherwise that write runs outside the transaction. Note that create() requires an array of documents when you pass options.
If you find yourself needing transactions for most writes, that is usually a sign the schema should embed more.
Pagination: Range Queries Instead of skip()
skip() looks simple, but the server still walks past every skipped entry, so page 500 is much slower than page 1. Results can also shift when new records are inserted between requests.
Range (cursor) pagination uses the last item of the previous page as the starting point:
type Cursor = { date: Date; id: Types.ObjectId } | null;
export async function listTransactions(
userId: Types.ObjectId,
accountId: Types.ObjectId,
cursor: Cursor,
limit = 20
) {
const filter: Record<string, unknown> = { userId, accountId };
if (cursor) {
filter.$or = [
{ date: { $lt: cursor.date } },
{ date: cursor.date, _id: { $lt: cursor.id } },
];
}
const items = await TransactionModel.find(filter)
.sort({ date: -1, _id: -1 })
.limit(limit + 1)
.lean();
const hasMore = items.length > limit;
const page = hasMore ? items.slice(0, limit) : items;
const last = page[page.length - 1];
return {
items: page,
nextCursor: hasMore && last ? { date: last.date, id: last._id } : null,
};
}_id is the tie-breaker because many transactions can share the same date. The { userId: 1, accountId: 1, date: -1, _id: -1 } index defined earlier supports this query exactly. Fetching limit + 1 items tells you whether another page exists without a separate count.
Aggregation Pipeline for Reports
Reports such as "spending per category this month" belong in the aggregation pipeline, not in a Node.js loop over thousands of documents.
import { Types } from "mongoose";
export async function monthlySpendByCategory(userId: string, start: Date, end: Date) {
return TransactionModel.aggregate<{ _id: string; total: number; count: number }>([
{
$match: {
userId: new Types.ObjectId(userId), // aggregate() does not cast types
type: "expense",
date: { $gte: start, $lt: end },
},
},
{
$group: {
_id: "$category",
total: { $sum: "$amount" },
count: { $sum: 1 },
},
},
{ $sort: { total: 1 } }, // expenses are negative, so most spent first
]);
}Two details matter here. Put $match first so the pipeline can use an index. And remember that Mongoose does not cast values inside aggregate() the way it does in find(), so a string userId silently matches nothing unless you convert it to an ObjectId.
If a report runs on every dashboard load, store its result with the computed or bucket pattern instead of recalculating it each time. For building the dashboard on top of this API, see the full-stack React, Node.js and MongoDB guide.
Common MongoDB Schema Design Mistakes
- Copying a relational schema one-to-one. Ten collections joined with
$lookupon every request throws away MongoDB's main advantage. - Unbounded arrays. Comments, transactions or log entries pushed onto a parent document will eventually slow every read and can approach the 16MB limit.
- Storing money as floating point numbers. Use integer minor units or
Decimal128. - Missing or misordered compound indexes. Follow ESR and confirm with
explain(). - Too many indexes. Every index slows writes and uses memory. Remove indexes no query uses.
- Trusting client-supplied user IDs. Always scope queries by the authenticated user on the server.
- Using skip() for deep pagination. Switch to range-based cursors.
- Duplicating fields without an update plan. Extended references need a strategy for when the source changes.
- Reaching for transactions by default. Model data so that most operations touch one document.
MongoDB Schema Design Checklist
- Access patterns listed with frequency before collections were designed
- Embedded only bounded data that is read together
- No arrays that grow with user activity
- Money stored as integers in minor units or
Decimal128 - Mongoose schemas with types, validation and
timestamps: true lean()used for read-only queries- Compound indexes follow Equality, Sort, Range
- Unique indexes enforce business rules; TTL indexes clean up temporary data
- Frequent queries verified with
explain("executionStats") $jsonSchemavalidator on critical collections- Transactions used only for multi-document invariants
- Range-based pagination for long lists
- Every query scoped by the authenticated user
Related Database and Backend Guides
- How to Build a Secure REST API with Node.js & MongoDB →JWT authentication, password hashing and API security for your MongoDB backend.
- Build a Full-Stack App with React, Node.js & MongoDB →End-to-end MERN architecture from API to UI.
- TypeScript with React and Node.js →Type-safe models, API contracts and shared types.
- Build an AI-Powered Web App with Next.js, Node.js and MongoDB →Using MongoDB as the data layer for AI features.
- HSFin Case Study →A React + Node.js + MongoDB personal finance dashboard with user-scoped data.
Frequently Asked Questions
Should I embed or reference in MongoDB?
Embed data that is read together and stays small and bounded. Reference data that grows without limit, is shared between many documents or is frequently accessed on its own.
What is the maximum document size in MongoDB?
A single BSON document can be at most 16 MB. In practice, design documents to stay far smaller, because large documents slow down reads and updates long before the limit.
What is the ESR rule for MongoDB indexes?
ESR stands for Equality, Sort, Range. In a compound index, put fields matched by equality first, then fields used for sorting, then fields filtered by range.
Does MongoDB need a schema?
MongoDB does not require one by default, but every application has an implicit schema. Defining it with Mongoose and $jsonSchema validation keeps data consistent and queries predictable.
When should I use MongoDB transactions?
Use multi-document transactions when one operation must update several documents atomically, such as a transfer between two accounts. Single-document writes are already atomic.
Why is skip() slow for pagination?
The server still has to walk past every skipped entry, so cost grows with the page number. Range-based pagination on an indexed sort key keeps every page equally fast.
Conclusion
MongoDB schema design is less about rules and more about one habit: design for the queries your application actually runs.
Start with access patterns, embed what is read together and bounded, reference what grows, and use patterns like subset, extended reference, computed, bucket and outlier when a plain model is not enough. Back it up with typed Mongoose schemas, ESR-ordered indexes verified with explain(), database-level validation and range-based pagination.
My recommendation: model the three most frequent queries of your app first, index them, and only then fill in the rest. That approach kept the HSFin data layer simple, and it will do the same for your next Node.js application.