Tuning Bulk Request Size and Concurrency

Your ingestion job runs at 6,000 documents per second and you have no idea whether that is the ceiling or a tenth of it. Guessing at batch_size = 1000 and workers = 8 is how most pipelines are configured, and it is why most pipelines leave half their throughput on the table or quietly saturate a node. This page runs a reproducible two-dimensional sweep — payload bytes × writer concurrency — and reads a defensible operating point off the results. It belongs to bulk indexing throughput tuning inside the data ingestion and synchronization pipelines area.

Prerequisites

  1. A disposable index on the target hardware with the production mapping applied — not a simplified test mapping.
  2. At least 500,000 representative documents available locally as newline-delimited JSON.
  3. Shell access to read _cat/thread_pool and _nodes/stats during a run.
  4. A quiet cluster: no concurrent query load, no other ingestion, no ongoing shard relocation.
  5. Roughly 90 minutes — a credible sweep is 12 to 16 runs of five minutes each.

Why a sweep beats a rule of thumb

Published advice (“5–15 MB per bulk request”) describes a distribution, not your index. Two variables move the optimum by more than an order of magnitude: average serialized document size and the number of analyzed fields in your mapping. A 400-byte log line with three keyword fields and a 12 KB product document with nested variants and eight analyzed text fields have almost nothing in common from the indexing engine’s point of view, even though both are “one document” to your application.

The second reason is that payload size and concurrency are not independent. In-flight bytes are roughly payload × concurrency × (1 + replicas), and it is the product that pressures heap. A configuration of 4 MB × 16 writers and one of 32 MB × 2 writers push similar byte volume but behave completely differently: the first spreads parse cost across many small requests and keeps every shard busy, while the second creates heavy-tailed latencies and heap spikes. Sweeping one variable while holding the other at a bad value produces a confident, wrong answer.

Throughput heat grid across payload size and concurrency A grid of measured throughput values where mid payload sizes with moderate concurrency form the best region, while large payloads with high concurrency degrade. concurrency → 2 4 8 16 2 MB 8 MB 16 MB 32 MB 9.1k 15.4k 21.0k 24.8k 17.6k 26.2k 28.4k 27.1k 19.9k 25.3k 22.8k 14.2k 18.4k 20.1k 13.6k rejected docs/sec best band degraded
A real sweep on 2 KB documents: the plateau spans 8 MB × 4–8 writers. The corner cases at both extremes are worse than the middle by a factor of two.

Solution Steps

1. Freeze the variables you are not measuring

Every run must differ in exactly one dimension. Fix refresh, replicas, and durability up front so they cannot drift between runs, and recreate the index between runs so segment state never carries over.

# reset_index.sh — run before EVERY sweep iteration
curl -s -X DELETE 'localhost:9200/bench' > /dev/null
curl -s -X PUT 'localhost:9200/bench' -H 'Content-Type: application/json' -d '{
  "settings": {
    "number_of_shards": 5,          
    "number_of_replicas": 0,        
    "refresh_interval": "-1",       
    "translog.durability": "async"  
  },
  "mappings": { "_source": {"enabled": true} }
}' > /dev/null

2. Drive the sweep from a harness that records everything

Throughput alone is not enough to pick a winner. Record rejections, p99 latency, and JVM pause time in the same row, because the fastest configuration that also rejects requests is not a valid operating point.

# sweep.py — two-dimensional sweep with per-run metrics
import itertools, json, statistics, subprocess, time
import concurrent.futures as cf, requests

ES = "http://localhost:9200"
PAYLOADS = [2, 8, 16, 32]        # MB per bulk request
CONCURRENCY = [2, 4, 8, 16]      # in-flight requests
RUN_SECONDS = 300                # 5 minutes; discard the first 120 s as warm-up

def node_counter(path):
    stats = requests.get(f"{ES}/_nodes/stats/thread_pool,jvm", timeout=10).json()
    return sum(_dig(n, path) for n in stats["nodes"].values())

def _dig(node, path):
    cur = node
    for k in path.split("."):
        cur = cur[k]
    return cur

def run_case(mb, workers, corpus):
    subprocess.run(["./reset_index.sh"], check=True)
    rej0 = node_counter("thread_pool.write.rejected")
    gc0 = node_counter("jvm.gc.collectors.young.collection_time_in_millis")
    lat, docs, t0 = [], 0, time.monotonic()
    with cf.ThreadPoolExecutor(max_workers=workers) as pool:
        futures = set()
        for payload, count in corpus.batches(mb * 1024 * 1024):
            if time.monotonic() - t0 > RUN_SECONDS:
                break
            futures.add(pool.submit(_post, payload))
            docs += count
            if len(futures) >= workers * 2:                # keep the pipe full, not flooded
                done, futures = cf.wait(futures, return_when=cf.FIRST_COMPLETED)
                lat.extend(f.result() for f in done)
    elapsed = time.monotonic() - t0
    return {
        "payload_mb": mb, "workers": workers,
        "docs_per_sec": round(docs / elapsed),
        "p99_ms": round(statistics.quantiles(lat, n=100)[98]) if lat else None,
        "rejected": node_counter("thread_pool.write.rejected") - rej0,
        "gc_ms": node_counter("jvm.gc.collectors.young.collection_time_in_millis") - gc0,
    }

def _post(payload: str) -> float:
    t = time.monotonic()
    r = requests.post(f"{ES}/_bulk", data=payload,
                      headers={"Content-Type": "application/x-ndjson"}, timeout=300)
    r.raise_for_status()
    return (time.monotonic() - t) * 1000

3. Read the winner off the results, not off the peak

Sort by throughput, then reject any row with a non-zero rejection count or a p99 beyond your ingestion latency budget. The operating point is the best surviving row backed off by one step, which leaves headroom for production’s noisier conditions.

rows = [run_case(mb, w, corpus) for mb, w in itertools.product(PAYLOADS, CONCURRENCY)]
valid = [r for r in rows if r["rejected"] == 0 and r["p99_ms"] < 2000]
best = max(valid, key=lambda r: r["docs_per_sec"])
print(json.dumps(best, indent=2))
# {
#   "payload_mb": 8, "workers": 8,
#   "docs_per_sec": 28400, "p99_ms": 612, "rejected": 0, "gc_ms": 3180
# }

4. Back off and pin the operating point

Run production one step below the measured peak. The sweep ran on a quiet cluster; production has query traffic, merges, and the occasional replica recovery competing for the same threads.

# ingest-config.yaml — derived from the sweep, with provenance in the file
bulk:
  payload_bytes: 8388608      # 8 MB — sweep peak
  concurrency: 4              # one step below the peak of 8, for production headroom
  max_retries: 6
  measured_on: "2026-07-26 / 3 nodes / 8 vCPU / 2 KB docs / 5 shards"
Operating point set below the measured peak A capacity bar showing the safe operating band up to seventy percent, a headroom band reserved for merges and query traffic, and the rejection zone beyond the measured peak. operating band — deploy here headroom rejections measured peak 28.4k/s deploy 20k/s Headroom absorbs merge bursts, replica recovery and query-time CPU contention. Running at the peak converts every transient into a retry storm.
The measured peak is a ceiling, not a target. Deploying one sweep step below it keeps merges current when production load returns.

5. Re-measure when anything structural changes

A sweep result has a shelf life. It is valid for the mapping, shard count, hardware, and document mix it was measured against, and any of those changing invalidates it. The three changes that most often silently move the optimum are adding an analyzed field (raises per-document cost, lowers the ideal payload), increasing shard count (raises achievable concurrency), and moving to instances with different disk throughput (moves the merge ceiling, which caps sustained rather than burst rate). Treat the sweep as a scheduled maintenance task tied to those events rather than a one-off, and keep the harness in the repository so re-running it costs an afternoon rather than a week of rediscovery.

Record the outcome where the next engineer will find it. A configuration file with a measured_on comment, as in step 4, is the minimum; a short entry in the runbook explaining why concurrency is 4 and not 8 prevents the well-meaning optimisation that doubles it during an incident. The most expensive part of tuning is not the measurement — it is doing the measurement three times because nobody wrote down the result.

Verification

Confirm the deployed job reproduces the measured rate within a reasonable margin:

# Sample the indexing rate over 60 seconds from the index stats counter.
before=$(curl -s 'localhost:9200/bench/_stats/indexing' | jq '._all.total.indexing.index_total')
sleep 60
after=$(curl -s 'localhost:9200/bench/_stats/indexing' | jq '._all.total.indexing.index_total')
echo "docs/sec: $(( (after - before) / 60 ))"
# Expected: within ~15% of the sweep value, e.g. 24000-28000 for a 28.4k peak
Sweep rate compared with the rate observed in production Bars comparing the benchmark peak, the configured deployment target, and the observed production rate, which lands within fifteen percent of target. sweep peak 28.4k/s target 20.0k/s observed 18.8k/s — within 6% A gap wider than about 15% means production is fighting something the sweep did not model.
Verification is a comparison, not a threshold: the deployed rate should track the target closely, and a large gap is a signal to look for competing load.

Confirm nothing is being rejected at the deployed setting — a single rejection means the operating point is too aggressive:

curl -s 'localhost:9200/_nodes/stats/thread_pool' \
  | jq '[.nodes[].thread_pool.write.rejected] | add'
# Expected: 0

Common Pitfalls

Measuring a warm-up transient instead of steady state

The first 60–120 seconds of any run index into empty buffers with no merge pressure, so throughput looks 30–50% higher than it will be ten minutes later. Discard the warm-up window explicitly, and always run long enough that at least one merge cycle completes; otherwise you are tuning for a state your production job spends 2% of its time in.

Sweeping against a simplified test mapping

A benchmark index with three fields and no analyzers will happily report 90,000 docs/sec, and the number is meaningless: indexing cost is dominated by analyzed-field and nested-document work that the simplified mapping does not have. Copy the production mapping verbatim, including analyzers and nested types, or the entire sweep measures the wrong system.

Ignoring rejections because the job "finished anyway"

Client-side retries hide rejections in wall-clock time rather than in errors, so a configuration that rejects 5% of requests still completes — slower, with a heavy latency tail. Treat any non-zero write.rejected delta as disqualifying, and re-measure at lower concurrency rather than raising queue_size, which only moves the queue from the server’s memory into a longer wait.

Operational notes

A sweep result is an input to capacity planning, not just to a config file. Once you know the sustained ceiling, every downstream question has an arithmetic answer: how long a full reindex takes, how much backlog an outage can accumulate before recovery exceeds the maintenance window, and how many nodes a doubling of the corpus will need. Write those three numbers into the runbook next to the configuration, because they are what an on-call engineer actually needs at three in the morning — not the payload size itself.

Finally, treat the sweep as a regression test for the ingestion path. Running it against a staging cluster after a mapping change, an engine upgrade, or an instance-type migration surfaces throughput regressions before they become production incidents, and the harness has already been written. A twenty-minute abbreviated sweep at three configurations is usually enough to detect a meaningful regression, which is cheap enough to run on every infrastructure change rather than once a year.