MongoDB schema less flexibility combined with Mongoose’s rich feature set makes it a powerful combination for Nodejs applications. However, this flexibility can lead developers down problematic paths that hurt application performance, maintainability and scalability. This comprehensive guide examines the most common Mongoose anti-patterns and provides actionable solutions to help you build better MongoDB applications.

Table of Contents

Over-normalization vs. Over-denormalization

One of the most critical decisions in MongoDB schema design is choosing between embedding documents and referencing them. Many developers coming from SQL backgrounds over-normalize their data, while others go to the opposite extreme and embed everything.

Anti-Pattern: Unbounded Arrays Inside Documents

Embedding large or unbounded arrays inside a document.

1
2
3
4
const userSchema = new mongoose.Schema({
  name: String,
  posts: [String] // Potentially unbounded array
});

MongoDB documents have a 16MB size limit. If the posts array grows indefinitely, you risk hitting this limit. Additionally, large arrays slow down document updates and queries.

Better approach:

Use referencing or pagination. Store posts in a separate collection and reference them by user ID.

1
2
3
4
5
const postSchema = new mongoose.Schema({
  userId: mongoose.Schema.Types.ObjectId,
  content: String,
  createdAt: Date,
});

Query posts separately with pagination:

1
const posts = await Post.find({ userId: user._id }).limit(20).skip(0);

Anti-Pattern: Unnecessary Referencing

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Bad: Over-normalized schema
const CommentSchema = new mongoose.Schema({
  text: String,
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  post: { type: mongoose.Schema.Types.ObjectId, ref: 'Post' },
  createdAt: Date
});

const PostSchema = new mongoose.Schema({
  title: String,
  content: String,
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  comments: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Comment' }]
});

This approach requires multiple queries to display a post with comments and author information, leading to poor performance.

Anti-Pattern: Over-denormalization

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Bad: Over-embedded schema
const PostSchema = new mongoose.Schema({
  title: String,
  content: String,
  author: {
    _id: mongoose.Schema.Types.ObjectId,
    name: String,
    email: String,
    bio: String,
    avatar: String,
    joinDate: Date,
    lastLogin: Date
  },
  comments: [{
    text: String,
    author: {
      _id: mongoose.Schema.Types.ObjectId,
      name: String,
      email: String,
      bio: String,
      avatar: String
    },
    createdAt: Date
  }]
});

This creates massive documents that are expensive to update and transfer over the network.

Best Practice: Balanced Approach

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Good: Balanced schema design
const PostSchema = new mongoose.Schema({
  title: String,
  content: String,
  author: {
    _id: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
    name: String,
    avatar: String
  },
  comments: [{
    text: String,
    author: {
      _id: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
      name: String,
      avatar: String
    },
    createdAt: { type: Date, default: Date.now }
  }],
  commentCount: { type: Number, default: 0 },
  createdAt: { type: Date, default: Date.now }
});

// Add index for efficient querying
PostSchema.index({ 'author._id': 1, createdAt: -1 });

Performance Implications: The balanced approach reduces the number of database queries while keeping document sizes manageable. Embedding frequently accessed, relatively static data (like author name and avatar) eliminates the need for joins while avoiding the overhead of large, frequently changing embedded documents.

Storing Dynamic Data in a Flat Schema

Anti-Pattern: Unstructured Metadata Fields

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Bad: Flat schema for dynamic data
const ProductSchema = new mongoose.Schema({
  name: String,
  price: Number,
  category: String,
  meta: mongoose.Schema.Types.Mixed, // Anything goes here
  customField1: String,
  customField2: Number,
  customField3: Boolean,
  // ... more ad-hoc fields
});

This approach leads to several problems:

  • No validation on the meta field
  • Difficult to query or index dynamic fields
  • Poor code readability and maintainability

Best Practice: Structured Dynamic Fields

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Good: Structured approach for dynamic data
const ProductSchema = new mongoose.Schema({
  name: { type: String, required: true },
  price: { type: Number, required: true, min: 0 },
  category: { type: String, required: true },
  attributes: [{
    key: { type: String, required: true },
    value: { type: mongoose.Schema.Types.Mixed, required: true },
    type: { 
      type: String, 
      enum: ['string', 'number', 'boolean', 'date'],
      required: true 
    }
  }],
  specifications: {
    type: Map,
    of: String
  }
});

// Add compound index for efficient attribute queries
ProductSchema.index({ 'attributes.key': 1, 'attributes.value': 1 });

Using Default Values for Complex Types Without Functions

Anti-Pattern: Shared Default Objects

1
2
3
4
5
6
7
8
9
// Bad: Shared reference to same array/object
const UserSchema = new mongoose.Schema({
  name: String,
  tags: { type: [String], default: [] },
  preferences: { 
    type: Object, 
    default: { theme: 'light', notifications: true } 
  }
});

This creates a shared reference problem where all documents without explicit values share the same array/object instance.

Best Practice: Use Functions for Complex Defaults

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Good: Function-based defaults
const UserSchema = new mongoose.Schema({
  name: String,
  tags: { type: [String], default: () => [] },
  preferences: { 
    type: {
      theme: { type: String, default: 'light' },
      notifications: { type: Boolean, default: true },
      language: { type: String, default: 'en' }
    },
    default: () => ({
      theme: 'light',
      notifications: true,
      language: 'en'
    })
  }
});

Improper Index Usage

Anti-Pattern: No Indexes or Too Many Indexes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Bad: No indexes on frequently queried fields
const OrderSchema = new mongoose.Schema({
  userId: mongoose.Schema.Types.ObjectId,
  status: String,
  createdAt: Date,
  total: Number,
  items: [ItemSchema]
});

// Or Bad: Over-indexing everything
OrderSchema.index({ userId: 1 });
OrderSchema.index({ status: 1 });
OrderSchema.index({ createdAt: 1 });
OrderSchema.index({ total: 1 });
OrderSchema.index({ userId: 1, status: 1 });
OrderSchema.index({ userId: 1, createdAt: 1 });
OrderSchema.index({ status: 1, createdAt: 1 });
// ... many more indexes

Performance Impact Example:

1
2
3
4
5
6
7
8
// Without index (slow)
const orders = await Order.find({ userId: someId, status: 'pending' });
// Execution time: ~100ms for 100,000 documents

// With compound index (fast)
OrderSchema.index({ userId: 1, status: 1 });
const orders = await Order.find({ userId: someId, status: 'pending' });
// Execution time: ~5ms for 100,000 documents

Best Practice: Strategic Indexing

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Good: Strategic index design based on query patterns
const OrderSchema = new mongoose.Schema({
  userId: mongoose.Schema.Types.ObjectId,
  status: { 
    type: String, 
    enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled'] 
  },
  createdAt: { type: Date, default: Date.now },
  total: Number,
  items: [ItemSchema]
});

// Compound index for most common query pattern
OrderSchema.index({ userId: 1, status: 1, createdAt: -1 });

// Sparse index for admin queries on high-value orders
OrderSchema.index({ total: -1 }, { sparse: true });

Related Reading:

Schema Validation Best Practices

Anti-Pattern: Weak Validation

1
2
3
4
5
6
// Bad: Minimal validation
const UserSchema = new mongoose.Schema({
  email: String,
  age: Number,
  password: String
});

This approach leads to data integrity issues and potential security vulnerabilities.

Best Practice: Strong Validation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Good: Comprehensive validation
const UserSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
    trim: true,
    lowercase: true,
    validate: {
      validator: function(v) {
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
      },
      message: props => `${props.value} is not a valid email address!`
    }
  },
  age: {
    type: Number,
    required: true,
    min: [13, 'Age must be at least 13'],
    max: [120, 'Age cannot exceed 120']
  },
  password: {
    type: String,
    required: true,
    minlength: [8, 'Password must be at least 8 characters long'],
    validate: {
      validator: function(v) {
        return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/.test(v);
      },
      message: 'Password must contain at least one uppercase letter, one lowercase letter, and one number'
    }
  }
});

// Custom validation for complex business rules
UserSchema.pre('save', async function(next) {
  if (this.isModified('password')) {
    this.password = await bcrypt.hash(this.password, 10);
  }
  next();
});

Related Reading:

Real-world Example: E-commerce Product Schema

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const ProductSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Product name is required'],
    trim: true,
    maxlength: [100, 'Product name cannot exceed 100 characters']
  },
  price: {
    type: Number,
    required: true,
    min: [0, 'Price cannot be negative'],
    validate: {
      validator: function(v) {
        return v % 0.01 === 0; // Ensure price is in cents
      },
      message: 'Price must be in cents (e.g., 1999 for $19.99)'
    }
  },
  stock: {
    type: Number,
    required: true,
    min: [0, 'Stock cannot be negative'],
    default: 0
  },
  categories: [{
    type: String,
    required: true,
    enum: {
      values: ['electronics', 'clothing', 'books', 'home'],
      message: '{VALUE} is not a valid category'
    }
  }],
  sku: {
    type: String,
    required: true,
    unique: true,
    validate: {
      validator: function(v) {
        return /^[A-Z]{2}-\d{6}$/.test(v);
      },
      message: 'SKU must be in format XX-000000'
    }
  }
});

// Compound index for common queries
ProductSchema.index({ categories: 1, price: 1 });
ProductSchema.index({ sku: 1 }, { unique: true });

Further Reading:

Conclusion

Avoiding these common Mongoose anti-patterns will significantly improve your application’s performance, maintainability and scalability. The key is to understand MongoDB’s strengths and design your schemas accordingly, rather than trying to force relational database patterns onto a document database.

Remember that optimization is an iterative process. Start with clean, readable code that follows these best practices, then profile and optimize based on your actual usage patterns and performance requirements.