ELK Stack — Indexing Performance
Indexing Performance
Indexing is the process of writing documents into Elasticsearch. In high-throughput environments — log ingestion, metrics collection, ETL pipelines — indexing speed is the primary bottleneck. This section covers every lever you can pull to maximize write performance.
The Bulk API
Never index documents one at a time. The bulk API amortizes the overhead of HTTP connections, request parsing, and shard routing across many documents in a single request.
curl -X POST "localhost:9200/_bulk" \
-H 'Content-Type: application/x-ndjson' -d'
{"index": {"_index": "logs-2026.04", "_id": "1"}}
{"@timestamp": "2026-04-01T10:00:00Z", "level": "INFO", "message": "App started"}
{"index": {"_index": "logs-2026.04", "_id": "2"}}
{"@timestamp": "2026-04-01T10:00:01Z", "level": "ERROR", "message": "Connection refused"}
{"index": {"_index": "logs-2026.04", "_id": "3"}}
{"@timestamp": "2026-04-01T10:00:02Z", "level": "WARN", "message": "Slow query detected"}
'
Bulk Sizing
| Bulk Size | Effect |
|---|---|
| < 1 MB | Too small — HTTP overhead dominates |
| 5–15 MB | Ideal range for most clusters |
| 15–50 MB | Can work on beefy nodes, test carefully |
| > 50 MB | Risk of request timeouts and memory pressure |
Tip: Measure in megabytes, not document count. 1000 documents at 1 KB each = 1 MB. 1000 documents at 50 KB each = 50 MB. Always size by payload.
Optimal Bulk Workers
Run multiple bulk requests in parallel from the client side:
| Data Nodes | Recommended Bulk Threads |
|---|---|
| 3 | 3–6 |
| 6 | 6–12 |
| 12 | 12–24 |
Start low and increase until you see 429 (too many requests) or CPU saturation.
Refresh Interval
By default, Elasticsearch refreshes every index every 1 second, making new documents searchable. During bulk loads, this is wasteful.
Increase During Bulk Loads
# Set refresh to 30 seconds during bulk ingestion
curl -X PUT "localhost:9200/logs-2026.04/_settings" \
-H 'Content-Type: application/json' -d'
{
"index.refresh_interval": "30s"
}'
Disable Completely During Initial Load
# Disable refresh entirely
curl -X PUT "localhost:9200/logs-2026.04/_settings" \
-H 'Content-Type: application/json' -d'
{
"index.refresh_interval": "-1"
}'
# ... perform bulk load ...
# Re-enable and force a refresh
curl -X PUT "localhost:9200/logs-2026.04/_settings" \
-H 'Content-Type: application/json' -d'
{
"index.refresh_interval": "1s"
}'
curl -X POST "localhost:9200/logs-2026.04/_refresh"
Replica Management During Load
Each replica doubles the indexing work. For initial data loads, temporarily set replicas to zero:
# Set replicas to 0
curl -X PUT "localhost:9200/logs-2026.04/_settings" \
-H 'Content-Type: application/json' -d'
{
"index.number_of_replicas": 0
}'
# ... perform bulk load ...
# Restore replicas
curl -X PUT "localhost:9200/logs-2026.04/_settings" \
-H 'Content-Type: application/json' -d'
{
"index.number_of_replicas": 1
}'
Warning: With 0 replicas, you have no fault tolerance. Only do this during initial bulk loads when the source data is still available for re-ingestion.
Translog Settings
The transaction log (translog) provides crash recovery. Every write goes to the translog before being applied to the Lucene index. By default, Elasticsearch fsyncs the translog after every request.
Async Translog for Higher Throughput
curl -X PUT "localhost:9200/logs-2026.04/_settings" \
-H 'Content-Type: application/json' -d'
{
"index.translog.durability": "async",
"index.translog.sync_interval": "30s",
"index.translog.flush_threshold_size": "1gb"
}'
| Setting | Default | Tuned |
|---|---|---|
durability |
request |
async |
sync_interval |
5s |
30s |
flush_threshold_size |
512mb |
1gb |
Warning: Async translog means you can lose up to
sync_intervalworth of data on a crash. Acceptable for logs, not for financial transactions.
Index Codec
Elasticsearch uses LZ4 compression by default for a balance of speed and compression. For warm/cold data where write speed is less important:
curl -X PUT "localhost:9200/logs-archive/_settings" \
-H 'Content-Type: application/json' -d'
{
"index.codec": "best_compression"
}'
| Codec | Compression | Speed |
|---|---|---|
default (LZ4) |
Good | Fast |
best_compression (DEFLATE) |
Better (~30% smaller) | Slower writes |
Note: Changing codec only affects new segments. Existing data keeps its original codec until a force merge.
Merge Policy
Lucene continuously merges small segments into larger ones. Tuning the merge policy controls how aggressively merging happens:
curl -X PUT "localhost:9200/logs-2026.04/_settings" \
-H 'Content-Type: application/json' -d'
{
"index.merge.policy.max_merged_segment": "5gb",
"index.merge.policy.segments_per_tier": 10,
"index.merge.policy.max_merge_at_once": 10,
"index.merge.scheduler.max_thread_count": 1
}'
| Setting | Default | Purpose |
|---|---|---|
max_merged_segment |
5 GB | Largest segment the merge policy will create |
segments_per_tier |
10 | How many segments per tier before merging |
max_merge_at_once |
10 | Max segments to merge in one operation |
max_thread_count |
max(1, cores/2) |
Merge threads — set to 1 for spinning disks |
Indexing Buffer
The indexing buffer holds documents in memory before they are written to a Lucene segment. The default is 10% of heap, shared across all shards on the node.
# elasticsearch.yml
indices.memory.index_buffer_size: 15%
indices.memory.min_index_buffer_size: 96mb
Increase the buffer if you have high indexing throughput and relatively few shards per node.
Force Merge
After an index is no longer being written to, force merge it down to a single segment per shard. This reduces file handles, heap usage, and improves search speed.
# Force merge to 1 segment per shard
curl -X POST "localhost:9200/logs-2026.03/_forcemerge?max_num_segments=1"
Warning: Force merge is I/O intensive and temporarily doubles disk usage for the index. Never run it on an index that is still receiving writes.
Automate via ILM
{
"warm": {
"min_age": "7d",
"actions": {
"forcemerge": {
"max_num_segments": 1
}
}
}
}
Data Streams
Data streams are the recommended way to manage time-series data. They automatically handle index creation, rollover, and ILM.
# Create an index template for a data stream
curl -X PUT "localhost:9200/_index_template/logs-template" \
-H 'Content-Type: application/json' -d'
{
"index_patterns": ["logs-*"],
"data_stream": {},
"template": {
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"index.refresh_interval": "5s",
"index.lifecycle.name": "logs-lifecycle"
}
}
}'
# Create the data stream
curl -X PUT "localhost:9200/_data_stream/logs-myapp"
# Index into the data stream (must use op_type=create)
curl -X POST "localhost:9200/logs-myapp/_doc" \
-H 'Content-Type: application/json' -d'
{
"@timestamp": "2026-04-01T10:00:00Z",
"level": "INFO",
"message": "Service started"
}'
Benchmarking with ES Rally
ES Rally is the official benchmarking tool for Elasticsearch. Always benchmark before and after tuning changes.
# Install ES Rally
pip install esrally
# Run the geonames benchmark against your cluster
esrally race --track=geonames --target-hosts=localhost:9200 --pipeline=benchmark-only
# Compare two runs
esrally compare --baseline=<run-id-1> --contender=<run-id-2>
Key metrics to watch:
| Metric | What It Measures |
|---|---|
Median Throughput |
Docs/sec during indexing |
Service Time (p99) |
99th percentile latency |
Error Rate |
Percentage of failed requests |
Merge Time |
Time spent merging segments |
Refresh Time |
Time spent refreshing |
Indexing Optimization Checklist
| Step | Action | Expected Impact |
|---|---|---|
| 1 | Use bulk API (5–15 MB payloads) | 10–50x throughput increase |
| 2 | Increase refresh_interval to 30s |
10–30% throughput increase |
| 3 | Set replicas to 0 during initial load | ~2x throughput increase |
| 4 | Use async translog | 10–20% throughput increase |
| 5 | Auto-generate _id (omit _id in bulk) |
10–15% throughput increase |
| 6 | Size bulk workers to match data nodes | Saturate cluster capacity |
| 7 | Force merge after write completes | Better search performance |
Quick Reference
| Setting | Recommended Value |
|---|---|
| Bulk payload size | 5–15 MB |
refresh_interval (ingestion) |
30s or -1 |
number_of_replicas (initial load) |
0 |
translog.durability (logs) |
async |
translog.sync_interval |
30s |
index.codec (warm/cold) |
best_compression |
merge.scheduler.max_thread_count (HDD) |
1 |
index_buffer_size |
10–15% of heap |
| Force merge target | 1 segment per shard |
← JVM and System Tuning · Next → Query Optimization