Benchmarks¶
Raw numbers behind the throughput race on the home page, generated by
perf.py against the bundled Rust/axum JSON server, 10,000 requests at concurrency
100 (plus a 1,000-request warm-up, untimed).
lothc, lothc-msgspec, and lothc-pydantic are the same HTTPClient, differing only in
response_data_type: lothc decodes into a plain dict (unvalidated),
lothc-msgspec into a real nested msgspec Struct, and lothc-pydantic into a real nested
pydantic BaseModel — both "typed" rows decode the exact same nested user/data/pagination
response shape the server returns, fully parsed and constructed, not a shortcut subset of it.
Every other library in this table gets exactly one row, because none of them have an equivalent
to response_data_type at all — httpx/httpx2/aiohttp/niquests/aiosonic/pyreqwest's
.json() (or similar) hands back a plain dict, full stop. Decoding that into a real, validated
object of your own type is something you'd write and maintain yourself on top of any of them,
because it isn't a feature those clients offer — it's a first-class, built-in part of lothc.
| Library | Total Time (s) | Throughput (req/s) | Relative (x) | CPU (s) | Peak Py Mem (MB) | Min (ms) | P50 (ms) | P95 (ms) | P99 (ms) | Max (ms) | Mean (ms) |
|---|---|---|---|---|---|---|---|---|---|---|---|
| httpx | 9.17 | 1,090 | 1.00 | 9.17 | 2.33 | 2.78 | 66.68 | 253.07 | 393.30 | 838.99 | 91.34 |
| httpx2 | 2.81 | 3,559 | 3.30 | 2.81 | 2.52 | 6.40 | 27.51 | 30.83 | 32.64 | 58.36 | 27.94 |
| niquests | 2.65 | 3,780 | 3.50 | 2.65 | 9.00 | 8.84 | 25.52 | 29.51 | 47.66 | 54.39 | 26.29 |
| aiosonic | 0.56 | 17,990 | 16.50 | 0.56 | 1.17 | 1.27 | 6.12 | 8.53 | 10.12 | 13.43 | 5.52 |
| aiohttp | 0.40 | 25,187 | 23.10 | 0.40 | 1.33 | 0.72 | 4.36 | 6.11 | 7.54 | 9.15 | 3.94 |
| lothc-pydantic | 0.20 | 48,955 | 44.90 | 0.20 | 0.63 | 1.20 | 2.02 | 2.14 | 2.86 | 3.10 | 2.02 |
| lothc | 0.20 | 50,347 | 46.20 | 0.20 | 0.63 | 0.51 | 1.95 | 2.51 | 2.97 | 3.37 | 1.96 |
| lothc-msgspec | 0.19 | 52,916 | 48.50 | 0.19 | 0.63 | 0.39 | 2.01 | 2.54 | 2.97 | 3.20 | 1.87 |
| pyreqwest | 0.18 | 54,556 | 50.00 | 0.18 | 0.51 | 0.78 | 1.91 | 2.43 | 2.61 | 3.04 | 1.81 |
Sorted slowest to fastest (x = throughput relative to the slowest library, httpx, in this run).
Worth calling out¶
lothc-msgspec outran even the unvalidated lothc row. Decoding into a real, nested,
fully-constructed msgspec Struct was faster than the plain dict path in this run
— msgspec's own JSON decoder is fast enough at parsing straight into structs that doing more work
(real typed construction, not just a dict) cost nothing extra. lothc-pydantic landed close
behind lothc too — full pydantic-core validation, at essentially the same speed as no
validation at all.
Re-run this yourself from a checkout of the repo with task perf (see
Development, and CLAUDE.md's Benchmarks section for the full set of options)
— pass --libs "lothc lothc-msgspec lothc-pydantic" to isolate just the decode-target comparison.
Sync sequential¶
The race above is pooled-concurrency, async-only — it says nothing about a plain blocking client
making one request at a time, which is its own common case (a script, a cron job, code that
isn't async at all). This one covers exactly that: one non-concurrent request at a time against
each library's blocking sync client, no event loop involved. aiohttp and aiosonic have no
sync client so aren't included; requests — the classic blocking-only library, with no async
client of its own — only appears here, never in the race above.
10,000 sequential requests (plus a 100-request warm-up, untimed) against the same bundled Rust/axum JSON server:
| Library | Total Time (s) | Throughput (req/s) | Relative (x) | CPU (s) | Peak Py Mem (MB) | Min (ms) | P50 (ms) | P95 (ms) | P99 (ms) | Max (ms) | Mean (ms) |
|---|---|---|---|---|---|---|---|---|---|---|---|
| requests | 3.35 | 2,986 | 1.00 | 3.09 | 1.00 | 0.28 | 0.33 | 0.35 | 0.52 | 0.85 | 0.34 |
| niquests | 2.87 | 3,489 | 1.20 | 2.63 | 0.96 | 0.23 | 0.28 | 0.32 | 0.50 | 1.16 | 0.29 |
| httpx | 2.34 | 4,270 | 1.40 | 2.16 | 0.75 | 0.16 | 0.22 | 0.33 | 0.50 | 14.48 | 0.23 |
| httpx2 | 2.05 | 4,889 | 1.60 | 1.86 | 0.39 | 0.17 | 0.20 | 0.22 | 0.29 | 0.76 | 0.20 |
| lothc-pydantic | 0.83 | 12,099 | 4.10 | 0.47 | 0.32 | 0.05 | 0.08 | 0.09 | 0.11 | 2.81 | 0.08 |
| lothc | 0.77 | 12,988 | 4.30 | 0.42 | 0.32 | 0.05 | 0.07 | 0.09 | 0.13 | 0.41 | 0.08 |
| lothc-msgspec | 0.74 | 13,484 | 4.50 | 0.39 | 0.31 | 0.04 | 0.07 | 0.08 | 0.09 | 0.23 | 0.07 |
| pyreqwest | 0.69 | 14,394 | 4.80 | 0.35 | 0.32 | 0.04 | 0.07 | 0.08 | 0.08 | 0.29 | 0.07 |
Without pooled concurrency to hide it, per-request Python overhead dominates far more starkly:
pyreqwest/lothc/lothc-msgspec/lothc-pydantic are within a few percent of each other and
roughly 4-5x faster than httpx/httpx2/niquests/requests, none of which have a Rust
transport underneath them.
Re-run this yourself with task perf-single-call-sync (see Development) —
regenerate the chart above from a fresh results file with task perf-svg -- results/single-call-
sync-<run_id>.json --out assets/perf-race-sync.svg.