high indexing throughput for the initial catalog load
An initial catalog load with several million product documents runs noticeably slower under Elasticsearch's default settings than the underlying hardware could actually deliver. The bulk API itself is rarely the bottleneck, it is the interplay of batch size, refresh interval, replica count, and thread pool configuration, which under default values is tuned for balanced continuous operation, not for a short, intensive bulk import. This article shows how to systematically determine the optimal batch size, which temporary setting changes have the biggest effect during a bulk load, and what a practical tuning checklist looks like for the initial indexing of a large catalog.
Table of Contents
- 1. The bulk API in its basic form
- 2. Systematically finding the optimal batch size
- 3. The trade-off between round trips and memory usage
- 4. Disabling the refresh interval during a bulk load
- 5. Temporarily reducing the replica count to zero
- 6. Keeping an eye on the thread pool and bulk queue
- 7. Handling partial bulk failures
- 8. A practical tuning checklist for the initial catalog load
- 9. Monitoring indexing throughput during the load phase
- 10. Summary
- 11. FAQ
1. The bulk API in its basic form
The bulk API combines multiple index, update, or delete operations into a single HTTP request, encoded as NDJSON with alternating action and source lines. Instead of sending a separate request for every single product document, an application transfers several hundred or thousand documents in one go, which drastically cuts the per-document overhead for HTTP headers, TCP handshakes, and connection setup.
Elasticsearch processes a bulk request internally through a dedicated write thread pool, sized in relation to the number of available CPU cores. When more work arrives at once than that pool can handle, further requests first land in a bounded queue before being rejected once that queue fills up as well.
POST _bulk
{ "index": { "_index": "products", "_id": "4711" } }
{ "sku": "abc-123", "price": 19.90 }
{ "index": { "_index": "products", "_id": "4712" } }
{ "sku": "abc-124", "price": 24.50 }
2. Systematically finding the optimal batch size
There is no universally correct batch size, since it depends on document size, network latency, and cluster hardware. A batch size that is too small, for example only a few dozen documents per request, generates unnecessarily many round trips and lets the relative overhead per request dominate. A batch size that is too large, on the other hand, stresses the coordinating node's heap, raises the risk of tripping the indexing circuit breaker, and can unpredictably increase individual request latency through longer garbage collection pauses.
A proven starting point is a batch size between five and fifteen megabytes of payload or a few thousand documents, whichever limit is hit first, followed by systematic load tests with increasing batch size under realistic network conditions. Throughput typically improves as batch size grows, reaches a plateau, and drops again for oversized batches due to memory pressure, so the optimum is best found empirically rather than theoretically.
3. The trade-off between round trips and memory usage
Every additional round trip costs network latency and processing overhead on both sides of the connection, regardless of the actual document size. With a thousand small batches of ten documents each, this overhead quickly adds up to substantial total runtime, even if the actual indexing work on the cluster would be minimal.
Larger batches reduce the number of round trips, but require the entire batch content to sit in memory simultaneously, both on the sending process and on the receiving coordinating node, before the individual operations can be distributed and processed. This memory demand scales linearly with batch size, which is why unbounded batch size increases eventually turn into memory pressure rather than more throughput.
4. Disabling the refresh interval during a bulk load
By default, Elasticsearch makes newly written documents searchable roughly every second through a refresh operation that creates a new, small Lucene segment. During a bulk load with a high write rate, these frequent refreshes add up to substantial overhead, since every new segment later needs merging with other segments, and visibility of individual documents during the initial load phase usually is not needed anyway.
For the duration of an initial catalog load, it therefore helps to set index.refresh_interval to -1, which fully disables automatic refreshes, and reset it to the desired production value, usually one second, once the bulk load is complete. A manual refresh at the end then makes the data searchable all at once in a controlled way, instead of continuously producing small segments.
PUT products/_settings
{ "index": { "refresh_interval": "-1", "number_of_replicas": 0 } }
// reset once the bulk load finishes
PUT products/_settings
{ "index": { "refresh_interval": "1s", "number_of_replicas": 1 } }
POST products/_refresh
5. Temporarily reducing the replica count to zero
With replicas enabled, every write operation must run not only on the primary shard but also on every replica shard before the bulk request counts as complete. During an initial bulk load, that doubles or multiplies the actual write effort across the cluster, without the replicas yet providing any practical benefit for resilience or read capacity during this phase.
A common practice is therefore to set number_of_replicas to zero for the duration of the initial load and only raise it again step by step afterward. Elasticsearch then creates the replica shards by copying the already fully populated primary shards, which is often faster overall than applying every single write operation to multiple shards at once throughout the entire load phase.
6. Keeping an eye on the thread pool and bulk queue
Every node's write thread pool has a fixed size, usually tied to the number of available processor cores, plus a bounded queue for requests that cannot be processed immediately. If an application sends more parallel bulk requests than the pool, together with its queue, can absorb, Elasticsearch returns an EsRejectedExecutionException error for the excess requests instead of buffering them indefinitely.
Through GET _nodes/stats/thread_pool, the current load, including rejected requests, can be observed per node. Instead of blindly raising the queue size, which only delays rejections rather than solving them, it is usually more effective to size the number of parallel client connections and their batch size so they match the cluster's actual processing capacity.
7. Handling partial bulk failures
A bulk request rarely fails entirely, far more often individual operations fail within an otherwise successful batch, for example due to a version conflict, a mapping error on a single document, or a triggered circuit breaker. The bulk API response carries an errors field that signals, when true, that at least one sub-operation failed, while the rest of the batch was still processed.
A robust client implementation must therefore check every element in the items array individually for an error code, rather than relying solely on the overall response's HTTP status code. Failed individual operations can then be retried in a targeted way, ideally with exponential backoff for errors caused by temporary overload, such as circuit breaker trips.
8. A practical tuning checklist for the initial catalog load
Before starting a large initial load, a fixed checklist is worth following: set refresh interval to minus one, reduce replica count to zero, set translog durability to async if the risk is acceptable, use a batch size determined through load tests, and size the number of parallel bulk clients to the available thread pool resources instead of blindly opening as many parallel connections as possible.
Once the bulk load finishes, the checklist also includes resetting all temporary settings back to production values, triggering a final manual refresh, and optionally kicking off a force merge that consolidates the many small segments produced during the load phase into fewer, more efficient segments before the index is released for production reads.
# 1. Before the bulk load
curl -X PUT "es:9200/products/_settings" -d '{"index":{"refresh_interval":"-1","number_of_replicas":0}}'
# 2. During the load phase: send bulk requests with the tested batch size
# 3. After completion
curl -X PUT "es:9200/products/_settings" -d '{"index":{"refresh_interval":"1s","number_of_replicas":1}}'
curl -X POST "es:9200/products/_refresh"
curl -X POST "es:9200/products/_forcemerge?max_num_segments=1"
9. Monitoring indexing throughput during the load phase
Without monitoring, tuning is just guessing. GET _nodes/stats/indices/indexing shows the current indexing rate per node, while GET _cat/thread_pool/write?v gives a quick overview of active and rejected requests in the write pool. Together, these two values show whether a bottleneck sits more in the cluster's processing capacity or in the client application's send rate.
A sudden drop in indexing rate with a steady bulk request rate usually points to memory pressure, frequent garbage collection pauses, or a triggered circuit breaker, visible in the corresponding counters under GET _nodes/stats/breaker. Watching these metrics regularly throughout the load phase makes it possible to adjust batch size or the number of parallel clients while the import is still running, instead of only reacting after a failed run.
| Setting | Default value | Value during bulk load | Effect |
|---|---|---|---|
| refresh_interval | 1s | -1 (disabled) | Fewer small segments, less merge overhead |
| number_of_replicas | 1 or more | 0 | No doubled write effort per document |
| translog.durability | request | async (if the risk is acceptable) | Less fsync overhead per bulk request |
| Batch size | Application dependent | 5-15 MB or a few thousand documents | Balanced ratio of round trips to memory |
| Parallel bulk clients | Often uncoordinated | Matched to thread pool capacity | Fewer rejections, steadier throughput |
Mironsoft
Search index setup, relevance tuning, and Magento search
Magento search that shows the wrong products first?
We set up Elasticsearch or OpenSearch for Magento cleanly, tune relevance and facets to the actual catalog, and optimize indexing processes for large catalogs.
Relevance Tuning
Match search results and facets to actual customer needs.
Search Migration
Guide a clean migration from Solr or MySQL search to Elasticsearch/OpenSearch.
Index Performance
Make indexing processes for large catalogs reliable and performant.
10. Summary
Bulk API Tuning: The Essentials at a Glance
Core principle
Batch size, refresh interval, replica count, and thread pool capacity need to be tuned together for a bulk load, not in isolation.
Biggest levers
Setting refresh_interval to -1 and number_of_replicas to 0 during the load phase usually has the biggest effect, both should be reset to production values afterward.
Finding batch size
No fixed value, determine it empirically through load testing, starting point five to fifteen megabytes of payload or a few thousand documents per request.
After the load phase
A manual refresh and an optional force merge consolidate the many small segments created during the bulk load before production reads resume.