Do you want your ad here?

Contact us to get your ad seen by thousands of users every day!

[email protected]

Java Concurrency Best Practices for MongoDB

  • June 12, 2025
  • 482 Unique Views
  • 11 min read
Table of Contents
Lost updatesDirty readsNon-repeatable readsPhantom readsHow to avoid these issues

In a multi-threaded, distributed environment like MongoDB, when clients execute queries concurrently, operations interleave with one another if they are not isolated, whether those operations involve single-document or multi-document operations.

For instance, Client C1’s read operation might observe the effects of a write performed by Client C2, even if that write has not yet been made durable. When at least one of the concurrent operations is a write and isolation is not enforced, this can lead to undesirable outcomes, such as:

  1. Lost updates.
  2. Dirty reads.
  3. Non-repeatable reads.
  4. Phantom reads.

In this article, we’ll look at some of the causes of these issues and how we can both resolve and avoid them entirely.

Lost updates

Writes to a single document in MongoDB are atomic. However, if an application reads a document, modifies it, and then writes it back, this entire read-modify-write cycle is not atomic. This scenario can result in a lost update situation, where two clients concurrently read the same document and then update it with different values, causing one client's changes to overwrite the other's changes.

The example below demonstrates this issue. Two threads read the same inventory document and update the quantity field independently. The reads and writes are not coordinated, meaning one thread’s update may overwrite the other’s, resulting in an inconsistent state.

package io.gitrebase;

import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;
import com.mongodb.client.model.Filters;
import com.mongodb.client.result.UpdateResult;
import org.bson.Document;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class InventoryUpdate {

    private static final String DATABASE_NAME = "test";
    private static final String COLLECTION_NAME = "inventory";

    public static void main(String[] args) {
        MongoClient mongoClient = MongoClients.create("mongodb://localhost:27017,localhost:27018,localhost:27019/?replicaSet=rs0");
        MongoDatabase database = mongoClient.getDatabase(DATABASE_NAME);
        MongoCollection<Document> collection = database.getCollection(COLLECTION_NAME);

        Document product = new Document("productCode", "PROD_001")
                .append("name", "Laptop")
                .append("quantity", 50);
        collection.insertOne(product);
        System.out.println("Product created: " + product.toJson());

        ExecutorService executorService = Executors.newFixedThreadPool(2);

        executorService.submit(() -> updateProductQuantity(collection, "PROD_001", -5));  // decrease by 5
        executorService.submit(() -> updateProductQuantity(collection, "PROD_001", 10));  // increase by 10

        executorService.shutdown();
    }

    public static void updateProductQuantity(MongoCollection<Document> collection, String productCode, int quantityChange) {
        try {
            // Find product by productCode
            Document productDoc = collection.find(Filters.eq("productCode", productCode)).first();

            if (productDoc != null) {
                int currentQuantity = productDoc.getInteger("quantity");
                int updatedQuantity = currentQuantity + quantityChange;

                Document updatedDoc = new Document("quantity", updatedQuantity);

                UpdateResult result = collection.updateOne(
                        Filters.eq("productCode", productCode),
                        new Document("$set", updatedDoc)
                );

                System.out.println("Updated product " + productCode + ": quantity changed by " + quantityChange +
                        " | New Quantity: " + updatedQuantity);
            } else {
                System.out.println("Product not found for code: " + productCode);
            }
        } catch (Exception e) {
            System.out.println("Error during update: " + e.getMessage());
        }
    }
}

To avoid lost updates, it's best to shift the responsibility for concurrency control to the database itself, where possible. For example, using atomic update operators like $inc allows MongoDB to apply changes directly without requiring a read-modify-write cycle in the application. This reduces the chance of conflicting updates and helps maintain data integrity even under concurrent access.

Dirty reads

A dirty read occurs when an application reads data that might later be rolled back or overwritten. In MongoDB, this can happen outside of transactions when clients read data that hasn’t yet been confirmed as durable across the replica set. For example, if a client writes to the primary and another client reads that data immediately, the read might return a value that hasn’t been replicated to a majority of nodes. If the primary crashes or steps down before replication completes, the write may be rolled back during the election process, meaning the read saw data that was effectively "undone."

While MongoDB prevents dirty reads within transactions by only making data visible after the transaction is committed, dirty reads can still occur outside of transactions if the application uses the default readConcern: "local". To avoid this, applications should use readConcern: "majority" to ensure that reads only return data that has been acknowledged by a majority of replica set members and is unlikely to be rolled back.

Non-repeatable reads

A non-repeatable read occurs when a client reads the same document multiple times within a session and receives different values because another client has modified the document in between reads.

For the example below, Client A reads a document before processing another query. Meanwhile, Client B modifies this document. Later, when Client A reads the same document again, it sees the modified version of the document, resulting in a non-repeatable read.

package io.gitrebase;

import com.mongodb.client.*;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import org.bson.Document;

public class NonRepeatableRead {

    private static final String DATABASE_NAME = "gitrebase";
    private static final String COLLECTION_PRODUCTS = "products";
    private static final String COLLECTION_ORDERS = "orders";

    public static void main(String[] args) throws InterruptedException {
        MongoClient mongoClient = MongoClients.create("mongodb://localhost:27017,localhost:27018,localhost:27019/?replicaSet=rs0");
        MongoDatabase database = mongoClient.getDatabase(DATABASE_NAME);
        MongoCollection<Document> products = database.getCollection(COLLECTION_PRODUCTS);
        MongoCollection<Document> orders = database.getCollection(COLLECTION_ORDERS);

        products.deleteMany(Filters.eq("category", "PIZZA"));
        Document pizza = new Document("_id", "PIZZA_001")
                .append("name", "Cheese Burst Pizza")
                .append("category", "PIZZA")
                .append("price", 350);
        products.insertOne(pizza);
        System.out.println("Inserted product: " + pizza.toJson());

        // Client A 
        Thread clientAThread = new Thread(() -> {
            try {
                // t1: Fetch product price
                System.out.println("Client A: Fetching product ...");
                Document firstRead = products.find(Filters.eq("_id", "PIZZA_001")).first();
                System.out.println("Client A : " + firstRead.toJson());

                // Simulate delay before placing order
                Thread.sleep(1000);

                // t3: Place an order with the price fetched at t1
                System.out.println("Client A: Placing order ...");
                orders.insertOne(new Document("orderId", "ORD_001")
                        .append("productId", "PIZZA_001")
                        .append("orderedPrice", firstRead.getInteger("price")));
                System.out.println("Client A: Order placed at t3.");

                // Simulate delay before fetching price again
                Thread.sleep(2000);

                // Fetch product price again
                System.out.println("Client A: Fetching product ...");
                Document secondRead = products.find(Filters.eq("_id", "PIZZA_001")).first();
                System.out.println("Client A : " + secondRead.toJson());

            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread clientBThread = new Thread(() -> {
            try {
                // Increment pizza price by 10%
                Thread.sleep(1000);  // ensure it happens after t1
                System.out.println("Client B: Incrementing pizza price by 10% ...");
                products.updateMany(Filters.eq("category", "PIZZA"), Updates.mul("price", 1.10));
                System.out.println("Client B: Price updated.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        clientAThread.start();
        clientBThread.start();

        clientAThread.join();
        clientBThread.join();
    }
}
  • At time t1, Client A issues findOne({ \_id: 'PIZZA\_001' }) on the products collection to retrieve product details.
  • At time t2, Client B updates all documents in the products collection where category = 'PIZZA' by incrementing their price by 10% using { $mul: { price: 1.10 } }.
  • At time t3, Client A places an order by inserting a document into the orders collection with the price fetched at t1.
  • At time t4, Client A issues findOne({ \_id: 'PIZZA\_001' }) again on the products collection and notices the price has increased.

At t4, Client A observes a different version of the document compared to the initial read at t1. This happens because the sequence of operations was not properly isolated. To avoid this, the entire sequence should be encapsulated within a multi-document transaction using a stronger read isolation level, ensuring the client always sees a consistent view of data throughout the session.

Phantom reads

A non-repeatable read occurs when the value of a document changes between two reads within the same session due to a concurrent write operation. A phantom read occurs when the result set of a query changes between executions in the same session because another client has inserted, deleted, or modified documents that affect the query’s outcome. As a result, the subsequent execution of the query returns a different result set than the first, even though no changes were made by the reading client.

In non-transactional operations, if a client uses a “cursor” to iterate over a result set, the same document can be returned in a result set more than once, or missed entirely, if another client modifies the underlying data while the cursor is still active. This behavior leads to an unstable result set and is a manifestation of either phantom or non-repeatable reads.

How to avoid these issues

To avoid these kinds of anomalies, MongoDB provides concurrency control mechanisms and configurable isolation properties, allowing clients to control the degree to which they observe the effects of concurrent operations.

Concurrency control allows a database to ensure operations execute in an orderly manner, preventing multiple clients from concurrently modifying the same resource and causing an inconsistent database state. At the document level, MongoDB guarantees atomic operations, even if an operation modifies multiple fields of the document, either completes entirely or does not occur at all. Clients either see the complete updated document or no changes at all.

MongoDB uses multi-version concurrency control (MVCC) to ensure concurrent write operations do not lead to lost updates or data inconsistencies. However, concurrency control alone may not be sufficient. Even with proper locking mechanisms, interactions between operations can still lead to anomalies such as dirty reads, non-repeatable reads, and phantom reads, emphasizing the importance of isolation.

Isolation

Isolation defines the degree to which the operations of one client remain hidden from other concurrently executing operations. It is a critical component of the ACID properties and directly influences the anomalies, such as dirty reads, non-repeatable reads, and phantom reads, that may occur during concurrent access.

Read concern

In MongoDB, read concern allows applications to control the isolation level of operations by specifying how visible the effects of concurrent writes should be. The read concern level determines whether a read returns uncommitted, in-memory data, or only durable data that has been acknowledged by a majority of replica set members. Read concern can be specified at the operation, session, or transaction level. If the read concern is not specified at the operation level, it defaults to the session, transaction, or replica set default.


Timeline of a write operation to a three-member replica set

We’ll use the following example to understand the read isolation: In a Primary–Secondary–Secondary (P–S–S) replica set, when a client issues a write at time t1, the primary writes to its oplog and applies the change. Secondaries begin replicating from the oplog at t2 and t3. Once a majority of nodes (in this case, one secondary plus the primary) apply the operation, the majority commit point is reached (t4), and the primary acknowledges the write to the client at t6. Secondaries independently update their read snapshots after processing entries up to the majority commit point (t7, t8).

Local

The “local” read concern returns data from the node’s in-memory view, without guaranteeing that the data is durable or replicated to other nodes.

In a replica set, if a read follows a write (without a stronger write concern) and the read uses readConcern: "local" and read preference as primary, data returned by the read operation has only been written to the primary and not yet acknowledged by a majority of the nodes. If the primary steps down before the write replicates to a majority of the nodes, the write will be rolled back during the election process. So the newly elected primary might not have the previously written data, causing the read operation to observe a value that no longer exists in the cluster. It offers local latency but no durability guarantees across the replica set. Use read concern as local, where absolute durability and consistency aren’t critical.

Applications using read concern as “local” will observe the following data in the example discussed:

Time Primary Secondary Secondary
t0 wpre wpre wpre
t1 w0 wpre wpre
t2 w0 w0 wpre
After t3 w0 w0 w0

Majority

The "majority" read concern returns data acknowledged by a majority of nodes in the replica set, providing a stronger consistency and durability guarantee. When a read operation is issued with "majority," the read node returns data from its in-memory view of the data at the majority-commit point, offering a higher level of consistency compared to other read concerns like "local" or "available." The majority read concern ensures stronger durability guarantees and prevents reading rollback-prone data.

The following table shows the data returned from the primary and secondary nodes when using the majority read concern.

Time Primary Secondary Secondary
Until t3 wpre wpre wpre
t4 w0 wpre wpre
t5 w0 wpre wpre
t6 w0 wpre wpre
t7 w0 w0 wpre
t8 w0 w0 w0

The rs.status().optimes.lastCommittedOpTime command returns the timestamp of the most recent operation that has been replicated to a majority of replica set members. Data returned by a read operation with the "majority" read concern is guaranteed to have been written before or at this timestamp and acknowledged by the majority of nodes in the replica set.

ReadConcern.MAJORITY can be used both inside and outside of transactions. Within a transaction, reads observe majority-committed data only if the transaction is eventually committed using writeConcern: "majority". Otherwise, the snapshot may not reflect data acknowledged by a majority.

Below, I’ve provided an example of ReadConcern.MAJORITY being used with MongoDB, which prevents rollback-prone reads.

package io.gitrebase;

import com.mongodb.ReadConcern;
import com.mongodb.ReadPreference;
import com.mongodb.TransactionOptions;
import com.mongodb.WriteConcern;
import com.mongodb.client.*;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import org.bson.Document;

public class ReadConcernMajority {

    private static final String DATABASE_NAME = "gitrebase";
    private static final String COLLECTION_PRODUCTS = "inventory";
    private static final String MONGO_URI = "mongodb://localhost:27017,localhost:27018,localhost:27019/?replicaSet=rs0";

    public static void main(String[] args) {
        MongoClient client = MongoClients.create(MONGO_URI);
        MongoDatabase database = client.getDatabase(DATABASE_NAME);

        // Reading collection with ReadConcern.MAJORITY
        MongoCollection<Document> products = database.getCollection(COLLECTION_PRODUCTS)
                .withReadConcern(ReadConcern.MAJORITY);

        products.deleteMany(Filters.eq("category", "PIZZA"));

        Document pizza = new Document("_id", "PIZZA_001")
                .append("name", "Cheese Burst Pizza")
                .append("category", "PIZZA")
                .append("price", 350);
        products.insertOne(pizza);

        Document result = products.find(Filters.eq("_id", "PIZZA_001"))
                .first();

        if (result != null) {
            System.out.println("Found pizza: " + result.toJson());
        }
    }
}

Snapshot

The "snapshot" read concern in MongoDB provides a consistent, point-in-time view of data throughout the duration of an operation. It uses WiredTiger’s checkpointing and timestamp-based data visibility to enable multi-document transactions with strong, repeatable-read isolation.

If a multi-document transaction uses \readConcern: "majority"\, different reads in the same transaction may still return different results due to interleaved writes. To avoid this, MongoDB provides \readConcern: "snapshot"\, which ensures a stable, point-in-time view of data throughout the transaction. This prevents both non-repeatable reads and phantom reads, enabling full repeatable-read isolation.

In the previous example of a non-repeatable read, the entire sequence of operation can be isolated from the write operation using the MongoDB transaction API with an appropriate read concern.

package io.gitrebase;

import com.mongodb.ReadConcern;
import com.mongodb.ReadPreference;
import com.mongodb.TransactionOptions;
import com.mongodb.WriteConcern;
import com.mongodb.client.*;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import org.bson.Document;

public class InventoryUpdateWithTransaction {

    private static final String DATABASE_NAME = "gitrebase";
    private static final String COLLECTION_PRODUCTS = "products";
    private static final String COLLECTION_ORDERS = "orders";
    private static final String MONGO_URI = "mongodb://localhost:27017,localhost:27018,localhost:27019/?replicaSet=rs0";

    public static void main(String[] args) throws InterruptedException {
        MongoClient client = MongoClients.create(MONGO_URI);
        MongoDatabase database = client.getDatabase(DATABASE_NAME);
        MongoCollection<Document> products = database.getCollection(COLLECTION_PRODUCTS);
        MongoCollection<Document> orders = database.getCollection(COLLECTION_ORDERS);

        products.deleteMany(Filters.eq("category", "PIZZA"));
        Document pizza = new Document("_id", "PIZZA_001")
                .append("name", "Cheese Burst Pizza")
                .append("category", "PIZZA")
                .append("price", 350);
        products.insertOne(pizza);
        System.out.println("Inserted product: " + pizza.toJson());

        // Write to the same collection from another thread
        Thread clientAThread = new Thread(() -> {

            final ClientSession clientSession = client.startSession();
            TransactionOptions txnOptions = TransactionOptions.builder()
                    .readPreference(ReadPreference.primary())
                    .readConcern(ReadConcern.SNAPSHOT)
                    .writeConcern(WriteConcern.MAJORITY)
                    .build();
            TransactionBody txnBody = (TransactionBody<String>) () -> {
                try {
                    System.out.println("Client A: Fetching product ...");
                    Document firstRead = products.find(clientSession, Filters.eq("_id", "PIZZA_001")).first();
                    System.out.println("Client A : " + firstRead.toJson());
                    Thread.sleep(3000);
                    System.out.println("Client A: Placing order ...");
                    orders.insertOne(new Document("orderId", "ORD_001")
                            .append("productId", "PIZZA_001"));
                    System.out.println("Client A: Order placed ");
                    Thread.sleep(1000);
                    System.out.println("Client A: Fetching product ...");
                    Document secondRead = products.find(clientSession, Filters.eq("_id", "PIZZA_001")).first();
                    System.out.println("Client A : " + secondRead.toJson());
                    return "Inserted into collections in different databases";
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            };
            try {
                clientSession.withTransaction(txnBody, txnOptions);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                clientSession.close();
            }
        });

        // Write to the same collection from another thread
        Thread clientBThread = new Thread(() -> {
            try {
                Thread.sleep(1000);
                // Increment pizza price by 10%
                System.out.println("Client B: Incrementing pizza price by 10% ...");
                products.updateMany(Filters.eq("category", "PIZZA"), Updates.mul("price", 1.10));
                System.out.println("Client B: Price updated.");
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        clientBThread.start();
        clientAThread.start();

        clientAThread.join();
        clientBThread.join();
    }
}

Using SNAPSHOT isolation ensures the client observes a consistent view throughout the transaction, even if a concurrent write operation happens outside. The second read shows the same price as the first, despite Client B's concurrent update.

Write concern

In MongoDB, write concern specifies the level of acknowledgment requested from MongoDB for write operations. It determines how much assurance an application requires before a write operation is considered successful. Write control lets us balance the trade-off between durability, consistency, and latency (the stronger the write concern, the stronger the durability and consistency guarantee). Write concern can be specified at the individual operation for single-document operations and at the transaction level for multi-document transactions

Unacknowledged

With an unacknowledged write concern, MongoDB does not need any acknowledgement from the data-bearing nodes to acknowledge a client write operation. This improves write performance but provides no guarantees about the success of the write or durability. Data will be rolled back if the primary steps down before the write operations have been written to the on-disk journal or replicated to any of the secondaries.

Acknowledged

Acknowledged write concerns ensure that write operation has propagated to the in-memory or journal of a standalone mongod or the primary in a replica set, before sending an acknowledgment back to the client for successful write. While specifying the write concern ( { w: 1, j: <boolean> } ), the value of j decides whether an acknowledgement to the client is sent before or after journaling. Both write concerns w:0 and w:1 don’t guarantee that the write will be made durable in case of a network partition.

Majority

The majority write concern requires acknowledgment from the majority of data-bearing nodes, offering the highest level of durability assurance against data loss due to node failures or rollbacks. While the majority write concern offers greater consistency and durability, it adds overhead to overall latency.

Write Concern ↓ / Journal Ack → j: false/unspecified j: true
w: 0 No guarantees on write, oplog, or journal Same as j: false, j: true has no effect
w: 1 Acknowledges writes on primary with no guarantee of replication to secondaries Written to the primary’s journal, but no guarantee of replication
w: majority Durable against primary node failure, but not guaranteed to be journaled Write is journaled on the primary and replicated to a majority of data-bearing nodes, ensuring durability and fault tolerance across multiple node failures

Concurrency control and Isolation are foundational for ensuring data integrity in distributed systems like MongoDB. While MongoDB guarantees atomic operations at the document level and leverages MVCC for concurrent write operations, proper selection of read and write concerns is essential to balance availability, consistency, and durability in distributed deployments. The following table illustrates how different combinations of read and write concerns influence availability and consistency requirements.

Write Concern ↓ / Read Concern → local majority snapshot
w:0 Availability: Highest Consistency: Lowest Availability: Medium-High Consistency: Low Availability: Low Consistency: Medium
w:1 Availability: High Consistency: Low Availability: Medium Consistency: Medium Availability: Low Consistency: Medium-High
w: majority Availability: Medium-High Consistency: Medium Availability: Medium Consistency: High Availability: Low Consistency: High

Unlike traditional databases that enforce a consistency model, MongoDB allows developers to fine-tune the balance between consistency and availability using read and write concerns.

This enables you to explicitly define the level of consistency guarantee that your application needs. Instead of a one-size-fits all approach, MongoDB gives developers the flexibility to prioritize the consistency and availability that applications need, making it an ideal choice for modern applications.

Do you want your ad here?

Contact us to get your ad seen by thousands of users every day!

[email protected]

Comments (0)

Highlight your code snippets using [code lang="language name"] shortcode. Just insert your code between opening and closing tag: [code lang="java"] code [/code]. Or specify another language.

No comments yet. Be the first.

Subscribe to foojay updates:

https://foojay.io/feed/
Copied to the clipboard