Sign inGet started

Performance

Elasticsearch Indexing Performance: Bulk, Refresh and Merges

Raise Elasticsearch indexing throughput with measured bulk sizes, refresh_interval, replicas, translog and auto IDs, and read the stats that show the limit.

15 min read · Elasticsearch 8.x and 9.x · Updated 21 Oct 2026

Slow indexing in Elasticsearch is rarely a single setting. It is a pipeline: clients build bulk requests, a coordinating node splits them by shard, primaries index each document into an in-memory buffer and a translog, replicas do the same work again, refreshes turn buffers into searchable segments, and merges keep the segment count under control. Throughput is set by whichever stage saturates first. The answer is to measure that stage instead of guessing: find a bulk size where throughput plateaus, use enough parallel clients to keep every data node busy, relax refresh during heavy loads, drop replicas only for initial loads, use auto-generated IDs where you can, and watch merge throttling and write rejections to know when you have reached the hardware's limit. This article walks through each stage, the settings that actually move throughput, and the stats that tell you which one is the bottleneck.

The mental model: what happens to one document

Follow a single document through a bulk request.

  1. The client sends a _bulk request to any node. That node acts as the coordinating node for the request.
  2. The coordinating node runs any ingest pipeline (on an ingest node), resolves the target index or data stream, computes the shard for each document by routing (by default a hash of the _id), and groups the documents into one shard-level bulk request per target shard.
  3. Each primary shard indexes its documents. For each document Lucene analyzes the fields and adds them to an in-memory indexing buffer, and Elasticsearch appends the operation to the shard's translog. With default durability, the translog is fsynced before the primary acknowledges.
  4. The primary forwards the operations to every replica. In a standard (stateful) Elasticsearch cluster, replicas re-index each document themselves; they do the same analysis and buffering work as the primary. A cluster with one replica does roughly twice the indexing CPU of a cluster with none.
  5. The bulk response returns once the primary and the in-sync replicas have the operations.
  6. Later, a refresh writes the buffer into a new Lucene segment and opens it for search. With the default index.refresh_interval of 1s, this happens roughly every second on shards that are being searched.
  7. Later still, a flush performs a Lucene commit, fsyncing segments to disk, so the translog can be trimmed.
  8. Merges run in the background, combining small segments into larger ones and expunging deleted documents.

Every one of these stages consumes CPU, disk I/O or heap, and they compete with each other and with search. Tuning indexing is mostly deciding how much of the cluster to give each stage.

Bulk request sizing: find the plateau

Single-document index requests carry per-request overhead: an HTTP round trip, a coordination step, a translog fsync per request with default durability. The bulk API amortizes all of it. Every high-throughput ingest path should use _bulk.

POST _bulk
{ "create": { "_index": "logs-app" } }
{ "@timestamp": "2026-10-21T08:15:02Z", "service": "checkout", "message": "payment authorized" }
{ "create": { "_index": "logs-app" } }
{ "@timestamp": "2026-10-21T08:15:03Z", "service": "checkout", "message": "order created" }

How big should a bulk request be? Elastic's documented approach is to measure rather than pick a number: benchmark on a single node with a single shard, start with a small batch (for example 100 documents), and double it until indexing speed stops improving. Elastic also warns against very large bulk requests, because they put memory pressure on the coordinating node and the shards; requests of a few tens of megabytes are a sensible upper region even if bigger ones appear faster in a test.

A common rule of thumb is to start around 5 to 15 MB per bulk request and adjust from measurements. Size in bytes is a better control than document count, because document sizes vary.

There is also a hard limit: http.max_content_length defaults to 100mb. A bulk request larger than that is rejected outright. Stay well below it.

Worked example: choosing a batch size

Assumptions: average document size 1.2 KB after JSON encoding; a single-shard benchmark index; a client that reports documents per second.

Batch (docs) Request size Measured docs/s (example run)
500 about 0.6 MB 18,000
1,000 about 1.2 MB 26,000
2,000 about 2.4 MB 31,000
4,000 about 4.8 MB 33,000
8,000 about 9.6 MB 33,500

These numbers are an illustration of the shape you are looking for, not a benchmark. The curve flattens between 4,000 and 8,000 documents. Pick the smallest size on the plateau, here about 4,000 documents or 5 MB, because larger requests add memory pressure and retry cost without adding throughput.

Parallelism matters as much as size

A single client thread sending one bulk at a time leaves the cluster idle while each response travels back. Use several concurrent bulk workers. Elastic's guidance is to increase the number of workers until either the cluster's I/O or CPU is saturated or you start to see rejections (HTTP 429). A rejection is a signal to back off, not to add more threads.

A rough starting point is enough concurrent bulk requests to keep each data node's write thread pool busy, then measure. More concurrency than the cluster can serve only fills queues.

The write thread pool and rejections

Shard-level indexing runs on the write thread pool. It is a fixed pool whose size is the number of allocated processors on the node, with a queue of 10000. When both the threads and the queue are full, new work is rejected with es_rejected_execution_exception and HTTP 429.

Check it per node:

GET _cat/thread_pool/write?v&h=node_name,name,active,queue,rejected,completed
node_name name  active queue rejected completed
es-hot-01 write      8     0        0  91234567
es-hot-02 write      8   412        0  90881223
es-hot-03 write      8  9876     1834  71245802

Read it this way: es-hot-03's queue is near full and rejections are climbing while its peers are idle or close to it. That is not a cluster-wide capacity problem, it is a hot node. A typical cause is that the shards of the index being written are concentrated on that node, often because the index has fewer primary shards than there are data nodes. More shards for the write-heavy index, or allocation settings that spread them, fix it. Raising the queue size does not: it only lets more work wait, using more heap, while latency rises. See thread pools and rejections for the full argument.

Indexing pressure

Separately from the thread pool, Elasticsearch tracks the bytes of outstanding indexing work on each node. When that exceeds indexing_pressure.memory.limit (default 10% of heap), new indexing requests are rejected. The counters are here:

GET _nodes/stats/indexing_pressure?filter_path=nodes.*.name,nodes.*.indexing_pressure.memory.total

Look at coordinating_rejections, primary_rejections and replica_rejections. Coordinating rejections usually mean bulk requests are too large or too many are in flight at once; primary and replica rejections mean the shards themselves cannot keep up.

Handle partial failures correctly

A bulk request returns HTTP 200 even when some items failed. The top-level errors field tells you whether any did:

{
  "took": 87,
  "errors": true,
  "items": [
    { "create": { "_index": "logs-app", "status": 201, "result": "created" } },
    { "create": { "_index": "logs-app", "status": 429,
                 "error": { "type": "es_rejected_execution_exception" } } }
  ]
}

Clients must inspect each item. Retry items with status 429 after an exponential backoff. Do not retry items that failed with mapping or document parsing errors (400-class statuses); they will fail again and should go to a dead-letter path. The official clients' bulk helpers implement this pattern; custom ingest code often does not.

Refresh, replicas and translog: the load-time levers

Three settings decide how much work each indexed document causes after it is acknowledged. They are the levers people reach for first, and each carries a different cost.

Refresh interval: the largest single lever

A refresh creates a new segment from the in-memory buffer. Each refresh costs CPU and creates a small segment that later has to be merged. The default index.refresh_interval is 1s.

Two refinements apply to the default:

  • Search idle shards. If index.refresh_interval is not explicitly set, a shard that has received no search requests for index.search.idle.after (default 30s) stops refreshing on the schedule. The next search triggers a refresh and waits for it. This makes write-heavy, rarely searched indices cheaper by default, at the cost of a slower first search.
  • Explicitly setting the interval disables that behavior. If you set "refresh_interval": "1s" explicitly, idle shards keep refreshing every second.

For write-heavy indices where users can tolerate data becoming searchable later, a longer interval reduces refresh and merge work:

PUT logs-app/_settings
{
  "index": {
    "refresh_interval": "30s"
  }
}

For a one-off bulk load into an index no one searches yet, disable it entirely and restore it afterwards:

PUT reindex-target/_settings
{
  "index": {
    "refresh_interval": "-1"
  }
}
PUT reindex-target/_settings
{
  "index": {
    "refresh_interval": null
  }
}

Setting it to null restores the default and with it the search idle behavior.

Avoid ?refresh=true on index requests in production ingest. It forces a refresh per request and produces a flood of tiny segments. If a caller needs read-your-writes for a specific request, ?refresh=wait_for waits for the next scheduled refresh instead of forcing one, which is far cheaper, though it still holds the request open.

Replicas during bulk loads

Because replicas repeat the indexing work, a one-replica index costs about twice the indexing CPU and I/O of a zero-replica index. For an initial load (a migration, a reindex into a new index, a backfill), Elastic's guidance is that you can set replicas to zero during the load and add them afterwards. Building a replica by copying the finished segments is cheaper than indexing every document twice.

PUT reindex-target/_settings
{
  "index": {
    "number_of_replicas": 0
  }
}

After the load:

PUT reindex-target/_settings
{
  "index": {
    "number_of_replicas": 1
  }
}

The risk is explicit: while replicas are zero, a single node failure loses the shards on it, and you must restart the load. Only do this when the source data is still available. Never do it on an index that is receiving live production writes; you are trading durability for speed on data you cannot replay.

Translog durability

The translog records every operation that has not yet been committed to Lucene, so it can be replayed after a crash. Its durability is set per index:

Setting Behavior Risk
index.translog.durability: request (default) Translog is fsynced before each index, delete, update or bulk request is acknowledged None beyond the disk itself
index.translog.durability: async Translog is fsynced every index.translog.sync_interval (default 5s) and on commit Operations acknowledged in the last interval can be lost if the node crashes

With request durability and bulk requests, the fsync is once per bulk per shard, not per document, which is another reason bulk sizes matter. Switching to async removes the fsync from the request path and can help on disks with slow fsync. It also means acknowledged writes can disappear on a crash. That is acceptable only if the client can replay from a source such as Kafka; see a logging architecture with a Kafka buffer. Treat it as a deliberate durability decision recorded in your design, not a tuning knob.

Document IDs: let Elasticsearch generate them

When you index a document with an explicit _id, Elasticsearch must check whether a document with that ID already exists in the shard, because the operation may be an overwrite. That is a lookup against the _id field across segments, and it gets more expensive as the shard grows.

When Elasticsearch generates the ID itself, it knows the document is new and can skip the lookup. Elastic's tuning guidance lists auto-generated IDs as an indexing speed improvement for this reason.

The trade-off is idempotence. With explicit IDs, a retried bulk item overwrites itself instead of creating a duplicate. With auto-generated IDs, a client that retries a request it thinks failed, but which actually succeeded, creates a duplicate.

ID strategy Indexing cost Retry behavior Good for
Auto-generated Lowest, no existence lookup Retries can create duplicates Logs and metrics where rare duplicates are tolerable
Explicit, from the source (database key, event ID) Higher, lookup on every write Retries are idempotent Entities that are updated, deduplicated pipelines
Explicit, hash of content Higher, lookup on every write Retries are idempotent, true duplicates collapse Deduplication of replayed streams

If you use explicit IDs, prefer values with a shared prefix or time-ordered structure over fully random ones; random IDs spread lookups across the whole term dictionary. Treat this as a rule of thumb whose benefit you should measure on your own data.

Data streams accept only create operations, not index operations that could overwrite, which suits append-only data.

Merges and indexing throttling

Every refresh creates a segment. Merges combine them. Merging is I/O heavy and runs concurrently with indexing on the same disks.

Elasticsearch's merge scheduler auto-throttles merge I/O. The number of concurrent merge threads per shard is controlled by index.merge.scheduler.max_thread_count, which defaults to Math.max(1, Math.min(4, <node.processors> / 2)). That default assumes SSDs. On spinning disks, which handle concurrent I/O badly, Elastic's guidance is to set it to 1.

If merging falls far enough behind, Elasticsearch throttles indexing itself on that shard to let merges catch up. You see a log line containing now throttling indexing, and the indexing stats show it:

GET _nodes/stats/indices/indexing,merges,refresh?filter_path=nodes.*.name,nodes.*.indices

The fields that matter:

{
  "indexing": {
    "index_total": 1844230113,
    "index_time_in_millis": 512330211,
    "index_current": 214,
    "is_throttled": false,
    "throttle_time_in_millis": 8823411
  },
  "merges": {
    "current": 6,
    "total_time_in_millis": 912334410,
    "total_throttled_time_in_millis": 401223099
  },
  "refresh": {
    "total": 12933410,
    "total_time_in_millis": 102331420
  }
}
  • indexing.throttle_time_in_millis rising between two samples means merges are throttling indexing. The disk is the bottleneck.
  • merges.total_throttled_time_in_millis is time merges spent being rate-limited themselves. A high value is normal under load.
  • index_time_in_millis / index_total over a sampling window gives average milliseconds per document on that node, a useful trend line.
  • Refresh time growing faster than indexing time suggests the refresh interval is too short for the write rate.

Things that reduce merge pressure: a longer refresh interval (fewer, larger segments), fewer updates and deletes (each leaves a deleted document for merges to clean up), and faster disks. Force merge is not an indexing tool; it is for indices that have stopped receiving writes.

The indexing buffer

The in-memory indexing buffer is shared by all shards on a node that are actively indexing, sized by indices.memory.index_buffer_size (default 10% of heap). Elastic's guidance is that a shard doing heavy indexing benefits from up to about 512 MB of buffer, beyond which returns are small. With the default, a node with a 30 GB heap has about 3 GB of buffer: enough for six heavily indexing shards at 512 MB each. If a node has many more actively indexing shards than that, each gets a smaller share and refreshes produce smaller segments.

Worked example. A node with a 16 GB heap has a 1.6 GB buffer. If 20 shards on it are indexing heavily, each gets about 80 MB. That is a sign of too many concurrently written shards on the node, which is more often a shard-count problem than a buffer problem. See shard sizing.

Sizing the write path: arithmetic

Assumptions: 2 TB of raw JSON per day; one replica; ingest is three times busier at peak than on average; six hot data nodes.

  1. Average rate: 2 TB / 86,400 s. Using 1 TB = 1,048,576 MB: 2,097,152 MB / 86,400 s, about 24.3 MB/s.
  2. Peak rate: 3 x 24.3, about 73 MB/s of incoming JSON.
  3. Indexing work including the replica: both copies index every document, so the cluster does the equivalent of about 146 MB/s of indexing at peak.
  4. Per node: 146 / 6, about 24 MB/s per node at peak.

The useful question is whether a single node can sustain about 24 MB/s of your documents, with your mappings and pipelines, alongside search. You answer that with the single-shard benchmark from the bulk sizing section, scaled to the node's shard count, not with a published number. If one shard on one node sustains 10 MB/s, each node needs at least three actively written primary or replica shards of the hot index to keep up, and that informs the hot index's primary shard count.

What else slows indexing

Cause Symptom Fix
Heavy ingest pipelines (grok, enrich, script) Ingest node CPU high; _nodes/stats/ingest shows large per-processor time Simplify parsing, use dissect where structure is fixed; see ingest pipelines vs Logstash
Dynamic mapping adding fields Frequent mapping updates; master busy with cluster state updates Explicit mappings, dynamic: strict or controlled templates; see mapping design
Too few primary shards for the write load One or two nodes have full write queues, others idle More primaries on the write index, or a rollover to a new layout
Updates instead of appends High merge and delete counts Model events as appends where possible
Index sorting Higher indexing CPU per document Accept it only where it pays for itself in search
Remote or network storage with slow fsync High latency per bulk even at low CPU Local SSDs; Elastic's tuning guidance notes that directly attached local storage generally performs better than remote storage
Swap Erratic stalls Disable swap; see JVM heap and circuit breakers

One anti-pattern deserves its own line: disabling _source to save indexing time. It saves a little disk and indexing work and costs you reindex, update and many debugging and upgrade paths. If storage is the concern, look at synthetic _source and codecs instead; see storage cost optimization.

Runbook: indexing is slow or rejected

  1. Check rejections and queues per node.

    http GET _cat/thread_pool/write?v&h=node_name,name,active,queue,rejected,completed&s=rejected:desc

    Rejections on one node point to shard placement. Rejections everywhere point to capacity or client concurrency.

  2. Check indexing pressure rejections in _nodes/stats/indexing_pressure. Coordinating rejections mean requests are too large or too concurrent.

  3. Find where the write index's shards live.

    http GET _cat/shards/logs-app*?v&h=index,shard,prirep,state,docs,store,node&s=node

    If the hot index's shards sit on a subset of nodes, that subset is your bottleneck.

  4. Sample indexing and merge stats twice, a minute apart. Rising throttle_time_in_millis means merges are holding indexing back: the disk is saturated.

  5. Look at what the busy node is doing.

    http GET _nodes/es-hot-03/hot_threads

    Threads in analysis, ingest processors, merges or translog fsync each point to a different fix.

  6. Check the client. Confirm it uses _bulk, checks errors per item, backs off on 429, and does not use refresh=true. Many slow pipelines are a client sending small requests serially.

  7. Apply the cheap levers for the workload: a longer refresh_interval on write-heavy indices; replicas at zero and refresh at -1 only for replayable initial loads; auto-generated IDs where duplicates are acceptable.

  8. Restore temporary settings after a bulk load: refresh interval to null or its normal value, replicas back to their target count, and check that the cluster returns to green.

Key takeaways

  • Indexing throughput is set by the first saturated stage: client concurrency, coordinating node, write threads, merges, or disk.
  • Find bulk size by measurement: double the batch until throughput plateaus, stay at a few tens of MB at most, and well below the 100 MB http.max_content_length.
  • Replicas repeat indexing work. Zero replicas and refresh_interval: -1 are for replayable initial loads only.
  • index.translog.durability: async can lose acknowledged writes on a crash. Use it only with a replayable source.
  • Auto-generated IDs skip an existence lookup; explicit IDs buy idempotent retries.
  • Rejections are backpressure. Spread the write load across shards and nodes rather than raising queue sizes.

How Sparced helps

Sparced reads thread pool statistics for every node in the same pass as nodes, shards and allocation, so write queue depth and rejection counts appear next to the shard layout that usually explains them. A finding names the node with rejections and shows the numbers it read, with the commands to inspect and confirm. It does not change refresh intervals, replicas or translog settings, and it does not benchmark your bulk size; it installs no agent and writes nothing to the cluster.