Why the standard indexing checklist doesn't fix everything
Every Elasticsearch tuning guide tells you the same three things: use bigger bulk requests, add more workers, stretch out the refresh interval. Those tips are correct, and they're also where most people stop. If your cluster is running on EBS-backed nodes, those tips will get you partway there and then plateau hard, because the bottleneck isn't your bulk size anymore. It's the storage layer underneath it.
EBS is not local NVMe. It's network-attached block storage with hard ceilings on IOPS and throughput, and those ceilings apply per volume and often per instance too. When you're indexing heavily, Elasticsearch is doing far more disk work than just writing your documents: translog fsyncs, segment flushes, and background merges are all competing for the same IOPS budget. On a local SSD that budget feels almost infinite. On EBS, you can hit the wall without ever seeing high CPU usage, which is exactly why generic tuning advice stops working.
This guide walks through how to actually find out whether your write bottleneck is hardware, merge policy, or your mappings, using the metrics that expose each one.
What makes EBS different for write-heavy workloads
A gp3 volume ships with a baseline IOPS and throughput allowance, and you can provision more, but there's still a cap. io2 volumes go higher, but the cap still exists. On top of the volume limit, the EC2 instance itself has an EBS-optimized throughput ceiling that's shared across every volume attached to it. If you've spread shards across multiple EBS volumes on the same node to increase IOPS headroom, you can still get throttled at the instance level even though no single volume looks saturated.
This matters for Elasticsearch specifically because a single shard's segment files live on one data path, they aren't striped across volumes. If you've got several hot shards landing on the same underlying volume, they'll contend for that volume's IOPS regardless of how much free CPU the node has. This is the multi-EBS-volume contention problem that generic tuning guides never mention, because it doesn't exist on bare metal with local NVMe.
The three places a write bottleneck actually lives
When indexing throughput drops or you start seeing EsRejectedExecutionException / HTTP 429s, there are really only three root causes worth chasing:
- Hardware ceiling — you're hitting the EBS volume's IOPS or throughput limit, or the instance-level throughput cap.
- Merge policy pressure — background segment merges are consuming disk I/O and CPU that indexing needs.
- Mapping overhead — your document structure is forcing Elasticsearch to do expensive CPU work per document before it ever touches disk.
Each one shows a different fingerprint in your metrics. The trick is knowing which numbers to look at together, not in isolation.
Reading the metrics that actually separate these three
Step 1: Check disk saturation, not just disk usage
Run iostat -x 1 on the node while indexing is happening, and watch the volume backing your path.data. Two numbers matter more than the rest: %util and await.
%utilnear 100% with highawaitmeans the disk queue is backed up, requests are waiting.- Low
%utilwith CPU pegged means the disk isn't the problem at all.
Pair this with CloudWatch EBS metrics if you're on AWS: VolumeQueueLength, VolumeThroughputPercentage, and BurstBalance if you're still on gp2. A queue length consistently above your volume's provisioned IOPS divided by expected latency is a clear sign you're hardware-bound, not configuration-bound.
Step 2: Correlate with the write thread pool
Elasticsearch exposes thread pool stats directly:
GET _nodes/stats/thread_pool
Look at the write pool (this was called bulk in older versions). If rejected is climbing while queue is maxed out, indexing requests are arriving faster than the node can absorb them. The question is why. If disk %util was also near 100% at the same timestamp, you're I/O bound and no amount of thread pool tuning fixes that. If disk was calm and CPU was maxed, you're compute bound.
Step 3: Isolate merge activity
Merges are the sneaky one because they look like generic disk I/O in iostat but they're self-inflicted by Elasticsearch, not caused by your bulk requests directly. Check:
GET _nodes/stats/indices/merges
A high total_time_in_millis relative to your indexing window, combined with disk %util spiking in bursts rather than staying flat, points at merge storms. This happens a lot on EBS because Elasticsearch's merge scheduler auto-tunes its concurrency based on whether it thinks the store is SSD-backed. EBS often gets detected as SSD-like even though its latency profile under load behaves more like a rate-limited network resource, so the scheduler can let more concurrent merges run than your provisioned IOPS can actually support.
Step 4: Rule out mapping-driven CPU cost
If CPU is high but disk is fine and merges are normal, look at your mappings before you look at hardware. Dynamic mapping explosions, deeply nested objects, and unnecessary text fields with heavy analyzers all cost CPU per document, before a single byte hits disk. A document with 400 mapped fields costs meaningfully more to index than one with 40, independent of document size. Check _mapping field counts and look for fields that got auto-mapped as both text and keyword when only one was needed.
Fixing each bottleneck once you've identified it
If you're hardware bound on EBS:
- Provision more IOPS/throughput on gp3, or move to io2 if you need consistent low-latency writes.
- Check the instance's EBS-optimized throughput limit, not just the volume's, larger instance types often unlock higher aggregate throughput even with the same volumes attached.
- Spread hot shards across more nodes rather than more volumes on one node, since the instance-level cap follows the node.
- Disable replicas during initial bulk loads, as the source documentation notes, since every replica write is additional I/O competing for the same disk budget.
If merges are the problem:
- Watch for merge time spiking disproportionately to indexing volume; that's your signal, not a guess.
- Avoid running force merges during active indexing windows, they're expensive and will fight your write traffic for the same IOPS.
- If you're batch-loading, disable refresh entirely with
refresh_interval: -1, then re-enable it after the load finishes. Fewer refreshes means fewer small segments, which means less merge work later.
If it's mapping overhead:
- Turn off dynamic mapping on high-volume indices and define an explicit mapping.
- Remove analyzers and
textfields from fields you only ever filter or aggregate on; usekeywordinstead. - Reduce field count where possible, especially for logging or event-style indices where every new key gets mapped automatically.
A monitoring setup that actually catches this early
You want disk metrics, thread pool metrics, and merge stats sitting on the same dashboard with the same timeline, so you can eyeball correlation instead of digging through three separate tools during an incident. If you're already scraping node exporter metrics, setting up Prometheus and Grafana with docker-compose gives you a working base to add Elasticsearch's own metrics exporter alongside node-level disk stats. The goal is one graph where a spike in write pool rejections lines up visually with either a disk %util spike or a CPU spike, so you know immediately which of the three culprits is active.
The bigger mistake to avoid
Don't jump straight to "add more IOPS" the first time you see rejections. Provisioned IOPS on EBS costs real money every month, and if your actual problem is a mapping explosion or an over-eager merge scheduler, you'll pay for hardware you didn't need. Diagnose first with the metrics above, then spend on the layer that's actually starved. Nine times out of ten it's a five-minute mapping fix or a refresh interval change, not a bigger volume.