Read Preferences, Write Concerns, and MongoDB at Scale

MongoDB performance at scale requires understanding the WiredTiger storage engine’s behaviour, designing for horizontal scaling with sharding, and using Atlas’s operational tooling to find and fix problems before they become outages. Read preferences control which replica set members handle reads β€” enabling geographic locality, read scaling, and reporting workloads without impacting the primary. Write concerns control durability guarantees. Sharding distributes data across multiple servers when a single node’s capacity is insufficient. This lesson covers these advanced operational patterns that production MongoDB deployments rely on.

Read Preference Modes

Mode Reads From Use For Staleness Risk
primary Primary only All writes and reads requiring latest data None β€” always current
primaryPreferred Primary if available, else secondary Tolerate brief primary unavailability Low
secondary Secondary only Reporting, analytics, background jobs Up to replication lag (usually <1s)
secondaryPreferred Secondary if available, else primary Read scaling for mostly-read workloads Low
nearest Lowest network latency member Multi-region: reads from closest DC Varies by region replication lag

Write Concern Options

Write Concern Acknowledges After Durability Latency
{ w: 0 } Immediately (no ack) None β€” fire and forget Minimal
{ w: 1 } Primary write Low β€” primary could crash before replication Low
{ w: 'majority' } Majority of replica set members High β€” survives primary failure Replication lag added
{ w: 'majority', j: true } Majority + journal flush Highest β€” survives crash + majority failover Highest
Note: Replication lag β€” the delay between a write committing on the primary and appearing on secondaries β€” is typically under 10ms on a well-configured replica set with network latency under 1ms. However, during high write load or network issues, lag can increase to seconds. Reads from secondaries with secondary read preference see data that may be seconds old. For most MEAN Stack applications, secondaryPreferred is a reasonable choice for read scaling β€” occasional stale reads are acceptable, and the primary is used as fallback.
Tip: Use read preference at the operation level, not just the connection level. Task.find(filter).read('secondary') (Mongoose) sets read preference for one query. This allows mixing: write operations and consistency-sensitive reads use the default (primary), while analytics queries, report generation, and export operations explicitly use secondary to offload the primary. This is more precise than setting a connection-level read preference that applies to all queries.
Warning: Sharding is a last-resort scaling solution, not a first step. A well-indexed MongoDB replica set on Atlas M50 (16GB RAM) handles tens of thousands of requests per second for most MEAN Stack workloads. Sharding adds significant complexity: shard key selection is critical and cannot be changed without resharding, queries that do not include the shard key become “scatter-gather” operations hitting all shards, and transactions become more complex. Exhaust all single-cluster optimisations (indexes, query optimisation, read scaling to secondaries) before considering sharding.

Complete Advanced MongoDB Operations

const mongoose = require('mongoose');
const Task     = require('../models/task.model');

// ── Per-operation read preference ─────────────────────────────────────────

// Primary for consistency-critical reads (after a write)
async function getTaskAfterUpdate(id) {
    return Task.findById(id)
               .read('primary')     // ensure we see the write
               .lean();
}

// Secondary for analytics and reporting β€” offloads the primary
async function generateTaskReport(userId) {
    return Task.aggregate([
        { $match: { user: new mongoose.Types.ObjectId(userId) } },
        { $group: {
            _id:      '$status',
            count:    { $sum: 1 },
            avgAge:   { $avg: { $subtract: [new Date(), '$createdAt'] } },
        }},
    ]).read('secondary');
}

// Nearest for multi-region: read from closest data centre
async function getUserTasks(userId) {
    return Task.find({ user: userId }).read('nearest').lean();
}

// ── Write concern per operation ───────────────────────────────────────────

// Low-durability for non-critical writes (user activity tracking)
async function recordTaskView(taskId, userId) {
    return Task.updateOne(
        { _id: taskId },
        { $inc: { viewCount: 1 }, $set: { lastViewedBy: userId } },
        { writeConcern: { w: 1 } }   // just primary ack β€” a missed view doesn't matter
    );
}

// High-durability for critical writes (payment, deletion)
async function permanentlyDeleteTask(taskId) {
    return Task.deleteOne(
        { _id: taskId },
        { writeConcern: { w: 'majority', j: true } }  // replicated + journaled
    );
}

// ── Collation β€” language-aware string sorting ─────────────────────────────
// Sort tasks alphabetically in French locale (handles accents correctly)
async function getTasksSorted(userId) {
    return Task.find({ user: userId })
               .collation({ locale: 'fr', strength: 1 })  // case-insensitive French sort
               .sort({ title: 1 })
               .lean();
}

// ── Partial index for soft-delete pattern ────────────────────────────────
// In schema definition:
// taskSchema.index(
//     { user: 1, createdAt: -1 },
//     { partialFilterExpression: { deletedAt: { $exists: false } } }
// );
// This index only contains non-deleted tasks β€” smaller, faster for common queries

// ── Atlas Performance Advisor β€” interpreting suggestions ─────────────────
// Atlas surfaces these via its UI; here's how to check manually:
async function checkSlowQueries() {
    const admin = mongoose.connection.db.admin();
    const profile = await mongoose.connection.db
        .collection('system.profile')
        .find({ millis: { $gt: 100 } })
        .sort({ ts: -1 })
        .limit(10)
        .toArray();

    return profile.map(op => ({
        operation:   op.op,
        collection:  op.ns,
        durationMs:  op.millis,
        docsExamined:op.docsExamined,
        docsReturned:op.nreturned,
        keysExamined:op.keysExamined,
        planSummary: op.planSummary,
    }));
}

// Enable profiling for slow queries (100ms threshold)
async function enableSlowQueryProfiling() {
    await mongoose.connection.db.command({
        profile: 1,
        slowms:  100,
        sampleRate: 0.5,   // profile 50% of slow queries to reduce overhead
    });
}

// ── Connection monitoring ─────────────────────────────────────────────────
mongoose.connection.on('commandStarted', event => {
    if (['find', 'aggregate', 'update', 'insert', 'delete'].includes(event.commandName)) {
        // Log command for query audit
    }
});

mongoose.connection.on('commandSucceeded', event => {
    if (event.duration > 100) {
        logger.warn('Slow MongoDB command', {
            command:    event.commandName,
            durationMs: event.duration,
        });
    }
});

How It Works

Step 1 β€” Secondary Reads Offload the Primary

In a 3-node replica set (1 primary, 2 secondaries), directing analytics queries to secondary read preference means the primary only handles writes and latency-sensitive reads. A reporting query that takes 5 seconds and accesses 100,000 documents runs on a secondary without impacting the primary’s CPU, memory, or I/O β€” it does not slow down the 50ms API response times on the primary. This is the most straightforward form of read scaling before considering sharding.

Step 2 β€” Write Concern Majority Survives Primary Failures

w: 'majority' means the write is acknowledged only after it has been replicated to a majority of replica set members. In a 3-node set, that means 2 nodes. If the primary crashes immediately after the write, one of the secondaries has the data and will become the new primary without data loss. With w: 1 (primary ack only), a crash before replication means the write is lost β€” even though the application received a success acknowledgement.

Step 3 β€” Collation Enables Correct Language-Aware Sorting

String sorting in MongoDB uses raw byte comparison by default β€” “Γ…ngstrΓΆm” sorts after “Zebra” because ‘Γ…’ has a higher code point than ‘Z’. Collation specifies a locale that defines language-specific rules: case folding, accent normalisation, and character equivalences. { locale: 'fr', strength: 1 } sorts case-insensitively with accents treated as equivalent to their base characters β€” “cafΓ©” and “Cafe” sort together as expected in a French language UI.

Step 4 β€” Partial Indexes Reduce Index Size and Maintenance Cost

A partial index on { user: 1, createdAt: -1 } with partialFilterExpression: { deletedAt: { $exists: false } } only indexes non-deleted documents. For a soft-delete pattern where 30% of documents are deleted, the index is 30% smaller and 30% cheaper to maintain on writes. Queries that always include { deletedAt: { $exists: false } } in their filter will use this index β€” saving I/O for both reads and write operations.

Step 5 β€” MongoDB Monitoring Events Track Command Performance

Mongoose exposes MongoDB’s Command Monitoring protocol via connection events: commandStarted, commandSucceeded, commandFailed. The commandSucceeded event includes the command duration in milliseconds. Logging slow commands (over 100ms) in production β€” without enabling the full slow query profiler β€” provides targeted visibility into database performance with minimal overhead. Atlas’s Performance Advisor also analyses these patterns automatically and suggests indexes.

Quick Reference

Task Code
Read from secondary Task.aggregate([...]).read('secondary')
Read from nearest Task.find(f).read('nearest')
High-durability write { writeConcern: { w: 'majority', j: true } }
Fire-and-forget write { writeConcern: { w: 0 } }
Language-aware sort .collation({ locale: 'fr', strength: 1 }).sort({ title: 1 })
Enable slow query log db.command({ profile: 1, slowms: 100 })
Read slow query log db.collection('system.profile').find({ millis: { $gt: 100 } })
Monitor slow commands connection.on('commandSucceeded', e => { if (e.duration > 100) warn(...) })

🧠 Test Yourself

A 3-node replica set (1 primary, 2 secondaries) uses { w: 1 } write concern. The primary acknowledges a write and immediately crashes before replicating to either secondary. What happens to the write?