Per-record calls versus batches: find the natural batch size
Measure your machineNeeds Python with requests
Problem
Every call across a boundary costs a fixed amount F (a round trip, request parsing, a commit) plus a per-record cost c. Sending N records in batches of B costs N/B × F + N × c, so batching divides the fixed cost by the batch size: throughput rises steeply until F/B is small next to c, then flattens. The price is latency: the first record of a batch waits for the whole batch to be sent and processed.
Start a local HTTP server that does a trivial amount of work per record. Send 100,000 records as batches of size 1, 10, 100, 1,000 and 10,000, with a JSON array in the body. Record records per second and the latency of the first record in each batch. Fit time per call = F + c × batch_size using two sizes, then compute where the fixed cost F equals the per-record cost.
The script caps batch size 1 at 5,000 calls to keep the run short.
The question. How does throughput grow with batch size, where does the fixed cost per call stop mattering, and what does the first record of a batch pay?
Predict
Expect throughput to rise steeply at first and then flatten, while the latency of the first record rises steadily. You should see exactly the F ÷ B curve and the price the first record pays.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
per-record-calls-versus-batches-find.py # /// script # requires-python = ">=3.10" # dependencies = ["requests"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Per-record calls against batches: find the fixed cost. A local HTTP server that does a trivial amount of work per record. 100,000 records sent as batches of 1 to 10,000, as a JSON array per call. Records per second, the latency of a batch's first record, and the fit time per call = F + c x batch size. """ import argparse, json, threading, time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import requests p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--records", type=int, default=100_000, help="Records sent per batch size.") p.add_argument("--sizes", type=lambda s: [int(v) for v in s.split(",")], default=[1, 10, 100, 1000, 10000], help="Batch sizes.") p.add_argument("--max-calls", type=int, default=5000, help="Cap on calls per size (batch size 1 sends this many records).") p.add_argument("--port", type=int, default=8139, help="Port for the local server.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() class H(BaseHTTPRequestHandler): def do_POST(self): body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) total = sum(r["qty"] * r["price"] for r in body) # trivial work per record out = json.dumps({"n": len(body), "total": total}).encode() self.send_response(200); self.send_header("Content-Length", str(len(out))); self.end_headers(); self.wfile.write(out) def log_message(self, *a): pass srv = ThreadingHTTPServer(("127.0.0.1", args.port), H) threading.Thread(target=srv.serve_forever, daemon=True).start() s = requests.Session(); url = f"http://127.0.0.1:{args.port}/" rows = [] for b in args.sizes: n = min(args.records, b * args.max_calls) batch = [{"id": i, "qty": 2, "price": 9.5} for i in range(b)] t0 = time.perf_counter() for _ in range(n // b): s.post(url, json=batch) dt = time.perf_counter() - t0 calls = n // b rows.append({"batch": b, "records_per_s": n / dt, "per_call_ms": dt / calls * 1000, "first_record_ms": dt / calls * 1000}) print(f"batch {b:>6,}: {n / dt:>10,.0f} records/s {dt / calls * 1000:7.2f} ms per call (the first record waits for the whole call)") srv.shutdown() # fit F + c*B from the smallest and largest batch a, z = rows[0], rows[-1] c = (z["per_call_ms"] - a["per_call_ms"]) / (z["batch"] - a["batch"]); F = a["per_call_ms"] - c * a["batch"] print(f"fit: F = {F:.3f} ms per call, c = {c * 1000:.2f} µs per record; F equals the per-record cost at a batch of {F / c:,.0f}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"batches": rows, "summary": [{"F_ms": F, "c_us": c * 1000, "crossover": F / c}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run per-record-calls-versus-batches-find.py uv run per-record-calls-versus-batches-find.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --records100,000 | Records sent per batch size. |
| --sizes1, 10, 100, 1000, 10,000 | Batch sizes. |
| --max-calls5000 | Cap on calls per size (batch size 1 sends this many records). |
| --port8139 | Port for the local server. |
Our run
Data table
| Records per call | Records / s |
|---|---|
| 1 | 1,670 |
| 10 | 12.5K |
| 100 | 161K |
| 1K | 708K |
| 10K | 1.18M |
Data table
| Records per call | ms per call |
|---|---|
| 1 | 0.597 |
| 10 | 0.802 |
| 100 | 0.621 |
| 1K | 1.41 |
| 10K | 8.48 |
batch 1: 1,674 records/s 0.60 ms per call (the first record waits for the whole call)
batch 10: 12,471 records/s 0.80 ms per call (the first record waits for the whole call)
batch 100: 161,001 records/s 0.62 ms per call (the first record waits for the whole call)
batch 1,000: 708,476 records/s 1.41 ms per call (the first record waits for the whole call)
batch 10,000: 1,179,884 records/s 8.48 ms per call (the first record waits for the whole call)
fit: F = 0.597 ms per call, c = 0.79 µs per record; F equals the per-record cost at a batch of 757What we found
Throughput rose from 1,670 records per second at one record per call to 1.18M at 10,000, flattening past 1,000, as expected. The fit gave F = 0.597 ms per call and c = 0.788 µs per record, so the fixed cost equals the per-record work at a batch of about 757 records. The first record of a batch waited for the whole call: 0.597 ms at batch 1 and 8.48 ms at batch 10,000.
Read it
A fixed cost per operation gives a natural batch size: about F/c records, where the fixed cost and the per-record cost are equal. Below it you pay mostly overhead; well above it you pay mostly latency, for no more throughput.
Batch by size and by age together (the next labs), so that a quiet period does not leave the first record waiting for a batch that never fills.