Bulk API Tuning for High Indexing Throughput
AI generated
_doc
_index
Elasticsearch / Ingest & Pipelines
Bulk API Tuning
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.

13 min read _bulk · Batch Size Refresh Interval · Replica Tuning

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.

11. FAQ: Bulk API Tuning: The Essentials at a Glance

1Why is Elasticsearch's default configuration suboptimal for a bulk load?
The default values are tuned for balanced continuous operation with simultaneous reads and writes, not for a short, very intensive bulk import without concurrent reads.
2How do you find the optimal batch size for the bulk API?
Systematically, through load tests with increasing batch size under realistic network conditions, starting from a value between five and fifteen megabytes of payload, until throughput reaches a plateau.
3Why should refresh_interval be disabled during a bulk load?
Because every automatic refresh creates a new, small Lucene segment that later needs merging. Disabling it considerably reduces this overhead during the load phase.
4What does setting number_of_replicas to zero achieve during the initial load?
Every replica doubles or multiplies the actual write effort per document. Without replicas during the load phase, that effort drops considerably, and replicas get created afterward by copying the finished primary shards.
5What happens when more parallel bulk requests are sent than the write thread pool can handle?
Excess requests first land in a bounded queue and then get rejected with an EsRejectedExecutionException error instead of being buffered indefinitely.
6How do you spot a partial failure in a bulk response?
Through the errors field, which signals true when at least one sub-operation in the batch failed. Every element in the items array must be checked individually for an error code.
7What role does translog.durability play in bulk tuning?
The async value reduces fsync overhead per bulk request, but raises the risk of losing recently written data on a node failure, a trade-off that needs to be weighed deliberately.
8Which metrics reveal a bottleneck during a bulk load?
The indexing rate from GET _nodes/stats/indices/indexing, thread pool load from GET _cat/thread_pool/write, and circuit breaker counters from GET _nodes/stats/breaker.
9What should happen once the bulk load finishes?
All temporary settings such as refresh_interval and number_of_replicas get reset to production values, a manual refresh gets triggered, and optionally a force merge that consolidates the many small segments.
10Why is an oversized batch size also problematic?
It puts more strain on the coordinating node's heap, raises the risk of a triggered circuit breaker, and can unpredictably increase individual request latency through longer garbage collection pauses.