Skip to content
About
Main content

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 , request parsing, a commit) plus a per-record cost c. Sending N records in batches of B costs N/B × F + N × c, so divides the fixed cost by the batch size: rises steeply until F/B is small next to c, then flattens. The price is : 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

At about what batch size does the fixed cost per call equal the per-record work?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    per-record-calls-versus-batches-find.py57 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Records per second by batch sizeApple M2 · 8 cores · 8 GB
Data table
Records per second by batch size
Records per callRecords / s
11,670
1012.5K
100161K
1K708K
10K1.18M
macOS 26.6.2 · Python 3.13.3 · requests 2.34.2 · run 2026-10-20
Time per call: what the first record of a batch waitsApple M2 · 8 cores · 8 GB
Data table
Time per call: what the first record of a batch waits
Records per callms per call
10.597
100.802
1000.621
1K1.41
10K8.48
macOS 26.6.2 · Python 3.13.3 · requests 2.34.2 · run 2026-10-20
Output · uv run per-record-calls-versus-batches-find.py
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 757

What 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.