TL;DR: I transformed a painfully slow MongoDB query (about 50 seconds) into a near-instant (sub-10ms) operation. This article showcases the entire process—from devising compound indexes, streamlining aggregation pipelines, and handling pagination and sorting, to exploring in-memory versus database-based sorting and employing Java virtual threads for efficient concurrency. If you’ve faced similar bottlenecks or timeouts, read on.
It all started when the application went live and the datasets from some thousands enlarged to many millions.
Initially, I believed that the queries would execute quickly, however user feedback indicated significant
timeouts. In practice, the aggregation pipeline was taking nearly 50 seconds to return a single paginated
set of results even for the last day. While some indexes were in place, the query plan consistently resorted
to a massive COLLSCAN (collection scan), highlighting several issues:
$skip + $limit pagination can degrade performance without proper indexing.This set the stage for an in-depth exploration of MongoDB and Spring Data, specifically focusing on performance improvements and concurrency strategies.
We employed an aggregator pipeline of this form:
db.users.aggregate([
{ "$match": { "$and": [...] } },
{ "$sort": { "date": -1 } },
{ "$skip": 0 },
{ "$limit": 50 }
]).explain("executionStats");
Although it looked straightforward—especially given that date was indexed—
explain("executionStats") painted a different picture:
"stage": "SORT",
"nReturned": 50,
"executionTimeMillisEstimate": 50957
At almost 51 seconds, the pipeline scanned thousands of documents, applying a sort in memory, and
then skipped to the relevant offset. This indicated that the index on date alone was
insufficient or mismatched.
Whenever your sort fields do not perfectly match the underlying index, MongoDB defaults to an in-memory sort, which is by far computationally expensive and explains the protracted runtime.
A multi-field index is invaluable if you filter or sort on multiple fields. For instance:
db.users.createIndex(
{ "userId": 1, "status": 1, "date.": -1 },
{ name: "userId_status_date_1" }
);
By aligning the fields used for filtering (userId, status) and sorting (date), you can vastly improve performance.
After addressing the issue with indexes, I assumed that apart from pagination batching will
reduce overhead by retrieving data in segments. so I ended up with an implementation like the below one:
int batchSize = 10000;
long totalProcessed = 0;
while (true) {
Aggregation batchAgg = Aggregation.newAggregation(
match,
sort,
Aggregation.skip(totalProcessed),
Aggregation.limit(batchSize),
project
);
List<Doc> batchResults = template.aggregate(batchAgg, ...).getMappedResults();
// ...
totalProcessed += batchResults.size();
}
Advantages:
Drawbacks:
Ultimately, batching proved not to be the primary solution for a query returning just 50 results. Correctly configured indexes remained the decisive factor.
I evaluated various techniques in the twilight of the batching failure:
$match, $sort, $skip, $limit, $project, and $lookup.find()
.hint() to force an index.Choose the approach that aligns with your data volume and transformation needs. For large datasets
or performance-critical queries, ensure $sort leverages a compound index.
Then came the request for a free-text search on text field and I have to introduce
Spring Data MongoDB TextCriteria:
TextCriteria textCriteria = TextCriteria.forDefaultLanguage().matching("hello");
However, TextCriteria implements CriteriaDefinition, not Criteria, meaning it cannot directly
plug into methods like new Criteria().andOperator(...). So I must either:
$match stages, one for standard filters and one for text search.$match.You also need a text index:
db.users.createIndex({ "description": "text"});
Text-based queries require a dedicated text index. Merging them with other filter criteria demands
careful handling, since TextCriteria is not the same as Criteria.
Our query logic involved two parallel tasks:
Initially, we used:
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<List<Doc>> resultsPromise = ...;
Future<Long> countPromise = ...;
While functional, scaling it up increased concerns about thread blocking. The advent of Project Loom (Java 19+ preview, or Java 21 stable) offered virtual threads:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// aggregator + count in parallel
}
Virtual threads consume fewer resources, allowing you to maintain a direct, blocking style without saturating a limited pool of OS threads. A very good choice for containerized applications.
On Java 21 or the Java 19 preview, virtual threads can greatly simplify concurrency. They handle large volumes of blocking tasks without the traditional overhead of a large pool of platform threads.
Ultimately, the following methods converged into our cohesive fix:
userId, status) plus the sort field (date).db.users.createIndex(
{ "userId": 1, "status": 1, "date": -1 },
{ name: "userId_status_date_-1" }
);
$match to filter, $sort utilizing the index, $skip and $limit for pagination, $project for specifying fields.$match stage or merge carefully.Result: We reduced a 50+ second operation to under 20ms or even single-digit milliseconds on
large datasets. The dreaded COLLSCAN or in-memory SORT was effectively avoided.
We benchmarked:
Possible Next Steps:
Core Message: Rely on explain("executionStats") to confirm the aggregator pipeline is properly
indexed. A suitable compound index and an efficient pipeline can drastically enhance performance.
[$match] -> [$sort] -> [$skip] -> [$limit] -> [results]
Illustrate where MongoDB might do an IXSCAN or COLLSCAN.
Compound Index
Demonstrate how (userId, status, date) in one index covers both filtering and sorting.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<List<Doc>> resultsPromise = executor.submit(() ->
template.aggregate(aggregation, "users", Doc.class).getMappedResults()
);
Future<Long> countPromise = executor.submit(() ->
template.count(query, Doc.class)
);
List<Doc> results = resultsPromise.get();
long totalCount = countPromise.get();
// Do something with results and totalCount
}
Here, both the aggregator and the count query run concurrently. This approach cuts waiting time in half for many scenarios.
What began as a routine query quickly escalated into a 50-second bottleneck. By leveraging compound indexes, concurrency (even via virtual threads), and occasionally in-memory sorting for edge cases, we achieved a robust and scalable solution.
If you find yourself dealing with $sort bottlenecks, heavy $skip usage, or text search integration
dilemmas, I hope this guide helps you refine your indexing strategy, concurrency approach, and
aggregator design. Best of luck, and may your queries always be fast!