MongoDB Array Updates: Fast, Atomic, Concurrency-Safe
See how we benchmark bulkWrite, aggregation, and $filter + $concatArrays for atomic, high-throughput MongoDB array updates under concurrency using k6.
Share this blog on
Updating nested arrays in MongoDB isn’t trivial, especially under heavy concurrency, where atomicity, performance, and scalability collide. In this post, we share the real journey of optimizing array updates for a production system serving thousands of concurrent users. From naive loops to bulkWrite(), aggregation pipelines, and $filter + $concatArrays strategies, we benchmark each approach using k6 to uncover latency spikes, lock contention, and real-world throughput. The result is a clear view of which update methods scale reliably under pressure, and practical guidance for engineers facing MongoDB bottlenecks in high-throughput, real-time workloads.
In high-throughput systems, MongoDB array updates can quickly become a serious performance bottleneck, specifically when atomicity and concurrency are non-negotiable. This becomes even more complex in live environments where real-time user data is in constant flux.
While optimizing a real-time academic platform, we faced this head-on. The goal: atomically update multiple student records inside a document without performance degradation or risking data races during peak usage.
What began as a simple loop of single update operations quickly revealed scalability issues. MongoDB’s aggregation pipelines and atomic update mechanics became central to our solution.
In this post, we unpack the entire journey from naive beginnings to a robust, concurrent-safe update strategy that holds strong under load.
The Challenge: Our Data Model and What Made It Tricky
The system we were working with followed a familiar structure - a school management app where each school document housed an embedded array of students. A typical document looked like this:
The problem statement was clear: we needed to update multiple student records inside the students
array of a single school document. Each update was matched by studentId
, and every update had to be applied in a single atomic operation.
Here’s what made it challenging:
Atomicity: All updates had to happen in one go. We couldn’t risk partial updates that left the document in an inconsistent state.
Concurrency: The platform served hundreds of concurrent users. If multiple clients tried updating the same school document simultaneously, the system needed to handle it gracefully.
Performance: With growing data volumes and frequent updates, the solution had to avoid locking or degrading under load.
An example update payload might look like:
In plain terms, we needed to find and update each student in the students
array using their studentId
, replace their fields with the new data, and write everything back atomically, all without stepping on the toes of other concurrent updates happening in real time.
MongoDB guarantees atomic writes at the document level, which gives us a good starting point. But that didn’t mean we could simply pull the document with findOne()
, modify the array in the app layer, and push it back with replaceOne()
. That approach was prone to race conditions - one writer could unknowingly overwrite another’s changes.
What we needed was a reliable, in-database way to surgically update just the relevant parts of the array - fast, safe, and atomic. The path forward would lead us through multiple strategies, trade-offs, and performance experiments.
MongoDB Array Update Performance Benchmarking with k6 (Concurrency & Atomicity)
To evaluate the real-world performance of our MongoDB update strategy, we needed a load testing companion that could simulate concurrency, generate meaningful metrics, and mirror production-like pressure. We approached this using k6, a widely trusted, open-source tool often integrated into DevOps workflows to validate systems before production.
Why k6? It checked all the right boxes:
JavaScript-based scripting: k6 uses a JavaScript DSL, which makes it easy to define dynamic scenarios like generating bulk update payloads or varying request rates with minimal setup.
Virtual Users (VUs): k6 models concurrency using Virtual Users. Each VU executes the script independently, enabling us to simulate dozens or even hundreds of parallel users hammering the system with update requests.
Real-time observability: k6 exposes key metrics like
http_req_duration
,http_reqs
, and throughput stats out of the box, allowing us to track both performance and stability under pressure.Scalable execution: Whether you want to run tests locally during development or scale them in the cloud for CI/CD pipelines, k6 offers flexibility with consistent output.
For our scenario, each Virtual User was tasked with generating a bulk update payload of 1,000 student objects and sending a single HTTP PUT request to our endpoint (/schools/1/students
). Here’s a snapshot of the k6 script we used:
We executed this script under different scenarios to assess behavior under varying load:
Each test gave us visibility into average request duration, throughput, and system responsiveness. These benchmarks helped validate whether our MongoDB update strategy could scale or whether we needed to go back to the drawing board. The insights from these tests shaped our final implementation, which we’ll break down next.
Why We Focused on Update Strategies, Not Schema Redesign
Before evaluating update strategies, we briefly considered whether changing the schema would offer a cleaner path, such as flattening students
into a top-level collection or using references instead of embedding.
While that approach could simplify individual updates and leverage document-level isolation, it comes with significant trade-offs.
This platform is already live with substantial production data, and restructuring the schema would mean complex data migration, changes to access patterns, and potential read performance hits. Our current design serves a specific purpose: most reads are scoped to a single school, and embedding students aligns well with those access patterns.
As for $push
-based update,s they work well for appending new records, not for targeted modifications. Since our goal is to update existing students matched by studentId
, push operations would introduce logic complexity or potential data duplication.
Given these realities, our focus remained on optimizing within the embedded MongoDB in an array structure, preserving atomicity, minimizing locking, and delivering consistent performance without upending the existing system architecture.
The First Trial: Naive For-Loop Updates
We began with the most straightforward approach - a simple for
loop that processes each student update one by one. In principle, the logic is sound: for every student object in the payload, find the matching document and use MongoDB’s positional $
operator to apply the update.
Here’s what that looked like in our Express + Mongoose handler:
At a glance, this brute-force method works. Each student gets updated, and MongoDB handles the rest. But under the hood, this approach is riddled with performance pitfalls:
High round-trip overhead: For 1,000 students, that’s 1,000 separate
updateOne
calls each with its own network latency and server processing time.No atomicity across the batch: Each update is independent. If the server crashes midway, you could end up with a half-updated
students
array.Lock contention: Each write operation acquires and releases a document-level lock, leading to more lock churn and potential contention.
Race conditions: If multiple clients execute this loop concurrently, updates may interleave unpredictably, possibly overwriting each other’s changes.
We ran our k6 test using 1 virtual user for 20 seconds. The results confirmed our concerns:
Latency spiked unpredictably, some requests taking over 2 seconds, with throughput topping out at ~27 requests per second. The method was simply not viable for high-concurrency or real-time workloads.
Optimizing MongoDB Array Updates with bulkWrite() for Performance
This allowed us to package all updates into one efficient, batched operation. The benefits were immediate:
Drastically reduced round-trip: All updates go through a single network call.
Ordered operations: By default,
bulkWrite()
stops at the first failure, giving partial atomicity.Server-side optimization: MongoDB can internally streamline the batch execution.
Running the same k6 test (1 VU, 20 seconds) yielded far superior results:
The latency dropped by nearly 90%, and throughput shot up to ~157 requests per second. bulkWrite()
proved to be a massive win in terms of raw speed and efficiency.
The Third Trial: Aggregation Update with $mergeObjects
Encouraged by bulkWrite’s success, we explored a more elegant, in-database strategy using MongoDB’s aggregation pipeline (supported in updateOne
since v4.2). We tried leveraging $map
, $filter
, and $mergeObjects
to build a single-statement update:
Conceptually, this approach is clean: iterate through each existing student, find a match in the update payload, and merge the updated fields. However, when we ran the k6 test, the numbers told a different story:
Despite the elegance, performance suffered severely. Why?
Heavy computation: Every update required scanning the input payload for each existing student, creating a mini in-document join.
Memory usage: MongoDB had to build new arrays in-memory while holding the original structure.
Inefficient indexing: Since the pipeline logic operated within the array, no indexes could be leveraged.
Pipeline overhead: The aggregation complexity outweighed the benefits of atomicity.
The method worked correctly, but was simply too slow for production traffic.
The Fourth Trial: $filter
+ $concatArrays
Instead of mapping over each student and filtering inside the loop, we tried a different tactic using $filter
and $concatArrays
. The idea: remove existing students that are about to be updated, then append the updated versions from the payload.
This approach simplifies the logic: filter out existing matches and append the new ones. It avoids merging, reduces iteration complexity, and avoids deeply nested filters.
The k6 results were significantly better:
The average latency dropped to ~13ms, far better than $mergeObjects
, though not quite at the bulkWrite()
level. Still, it offered a good trade-off between simplicity, atomicity, and performance. Taken together, these results highlight how different strategies affect MongoDB performance tuning under load.
Why this approach worked:
Single pass:
$filter
does a simple exclusion, and$concatArrays
appends in one go.Less memory overhead: No need to compute per-element diffs.
Minimal locking: It's still a single atomic operation, with faster internal execution.
Lower complexity: The query plan is lighter, avoiding deeply nested operations.
Summary of Single-User Trials
Method | Avg Latency | Max Latency | Requests/sec |
---|---|---|---|
Naive For-Loop | ~34.1ms | ~2.34s | ~27.4/s |
| ~4.14ms | ~60ms | ~157.4/s |
| ~259.1ms | ~377ms | ~3.8/s |
| ~12.9ms | ~68ms | ~66.6/s |
At first glance, bulkWrite()
clearly dominates in raw speed and throughput. The $filter + $concatArrays
approach is a strong contender when single-statement atomicity is preferred. $mergeObjects
provided correct results but performed poorly under pressure. And the naive loop, unsurprisingly, fell apart under load.
But remember, all of this was with one virtual user. The true test of a strategy’s resilience comes under concurrent load. What happens when 10 users fire updates at once? That’s when race conditions and document locking really come into play, and that’s where we go next.
Impact of Concurrency on MongoDB Write Performance
With the single-user benchmarks behind us, it was time for the real trial - how do these strategies behave under high concurrency? We ramped up the test using 10 virtual users over the course of 1 minute, simulating multiple clients simultaneously updating the same school document with 1,000 student records each.
BulkWrite under Load (10 VUs, 1m)
Each virtual user sent a batch of 1,000 updates via bulkWrite()
- identical to the previous approach, but now with overlapping write windows.
Results:
While the throughput remained consistent (~154 req/s, same as the 1-VU test), the max latency exploded to 2.68 seconds. That’s a significant outlier and a red flag for real-time systems.
Filter + ConcatArrays under Load (10 VUs, 1m)
We then ran the same test using the $filter + $concatArrays
strategy. Each request performed a single atomic update to the entire array in one operation.
Results:
Throughput stayed nearly identical (~154.2 req/s), but here’s the key difference: maximum latency stayed well below 300ms. No huge spikes. Performance remained smooth and predictable, even with contention.
What Just Happened?
The answer lies in how MongoDB handles locking, specifically document-level locking in the WiredTiger storage engine. MongoDB’s WiredTiger concurrency model ensures only one operation at a time can modify a document, which means concurrent writes to large arrays often queue up behind each other. MongoDB guarantees atomicity for single-document writes, but when multiple writes target the same document, they serialize instead of running in parallel.
With bulkWrite()
, even though it’s a single function call, it executes 1,000 individual updates under the hood - each one acquiring and releasing the document lock. When 10 users run this in parallel, MongoDB must serialize those lock requests. Occasionally, one request ends up waiting far longer, resulting in latency spikes of multiple seconds.
By contrast, the $filter + $concatArrays
strategy issues a single atomic update per request. The document lock is acquired once, the array is replaced in full, and the lock is released. Collisions still happen, but because each operation completes quickly (around 12–13ms in earlier tests), wait times are shorter and more stable. The net result is a much more consistent latency profile under concurrent access.
Key Insight
The takeaway aligns with MongoDB’s architecture notes:
“WiredTiger provides document-level locking... ensuring that only a particular document is locked during active operations.”
Yes, writes to a single document are atomic. But the more frequently you acquire and release the lock, the more likely contention becomes a bottleneck under concurrent load.
In short:
bulkWrite()
excels in single-user performance but suffers under concurrency due to repeated lock contention.$filter + $concatArrays
offers a strong balance of atomicity and concurrency resilience - fewer lock cycles, faster completion, smoother scalability.
Best Practices for High-Performance MongoDB Array Updates Under Concurrency
After pushing the system to its limits, we returned with a deeper understanding of MongoDB’s behavior under real-world conditions, especially when dealing with array updates, atomicity, and lock contention. Here are the key engineering takeaways:
Benchmark under realistic load - don’t rely on intuition
What seems performant in isolation can fail under concurrency. BulkWrite delivered great single-user numbers, but buckled when multiple clients hit the same document.Don’t ignore max latency
Average response times can be misleading. A 4ms average sounds great until a request hits a 2.7s spike. Max latency is often more indicative of user experience and system bottlenecks.Understand MongoDB’s document-level locking model
MongoDB locks entire documents for the duration of a write. More frequent writes mean more locking. Reducing the number of discrete write operations per request helps avoid contention.Leverage atomic single-document operations
MongoDB guarantees atomicity for updates on a single document. Make the most of this by doing all relevant updates, even complex ones in a single$set
or aggregation pipeline.Use
bulkWrite()
carefully in high-contention scenarios
WhilebulkWrite()
shines for throughput and batching, it internally executes multiple operations, each acquiring the document lock. When many clients bulk-write the same document, lock contention can throttle performance.Aggregation pipelines can express complex logic at a cost
Operators like$mergeObjects
,$filter
, and$concatArrays
can surgically update arrays, but introduce computational overhead. Simpler is often better when concurrency is a concern.Design updates to minimize lock duration
The$filter + $concatArrays
strategy worked well because it made one clean update with minimal lock time. Fewer lock acquisitions mean better stability under load.Indexing helps find documents, but not always with updates
While indexes speed up queries, updates, especially to nested array elements, still involve scanning and modifying the entire document. Smart update design matters more than index tuning in these cases.Set up observability for write behavior
Use monitoring tools to track write latency, lock wait times, and queue lengths. These metrics often surface performance issues before users do.
MongoDB gives you the tools to write expressive, atomic updates, but the real test is how those strategies behave at scale, under concurrency, and in live environments.
Selecting the Most Reliable MongoDB Array Update Technique
When optimizing nested array updates in MongoDB, performance under concurrency is as critical as atomic correctness. While bulkWrite()
achieved the lowest average latency in single-user scenarios, it suffered from lock contention during concurrent execution due to multiple internal write operations per request.
In contrast, the $filter
+ $concatArrays
aggregation approach delivered stable, predictable performance by minimizing lock duration through a single atomic update. This consistency makes it a more robust solution for high-concurrency environments.
Final insights:
Benchmark with realistic concurrency to expose hidden bottlenecks.
Use single-statement atomic updates to reduce document-level lock churn.
Optimize for consistent latency rather than isolated peak throughput.
Prefer simpler, aggregation-based strategies when updating large arrays in shared documents.
In production systems where scalability, consistency, and real-time performance matter, the right update strategy isn’t just about speed; it’s about control under pressure. Whether you’re dealing with slow $push
operations, concurrent writes, or need better MongoDB write optimization, the right update pattern makes all the difference.
If you found this post valuable, I’d love to hear your thoughts. Let’s connect and continue the conversation on LinkedIn.