Choosing Between httpx and aiohttp¶
httpx and aiohttp are the two mainstream async HTTP clients, and for a handful of requests at a time either one is fine. They differ in API philosophy, features, and — at high concurrency — in performance by more than an order of magnitude. Measured on Python 3.14 with httpx 0.28.1 (httpcore 1.0.9) and aiohttp 3.14.3, against a local server that answers after 50 ms: at 10 concurrent requests both were close to the ideal 200 per second (httpx 186, aiohttp 192). At 100 concurrent requests, aiohttp reached 1,701 req/s and httpx fell to 129 — lower than at 10. The httpx client was CPU-bound: 7.3 s of CPU for 1,000 requests, against 0.69 s at concurrency 10. This guide compares the two on what matters for choosing, including that measurement and when it applies to you.
Prerequisites¶
- Python 3.11+,
pip install httpx aiohttp. - Connection pools, from Connection Pooling & Keep-Alive.
- Your workload's concurrency: how many requests a single process has in flight at peak.
1. Compare the APIs¶
The two read differently. httpx mirrors requests, with a matching synchronous client; aiohttp is async-only and uses context managers for responses:
# httpx: requests-style, sync and async twins
async with httpx.AsyncClient(base_url="https://api.example.com", timeout=10) as client:
r = await client.get("/users/1")
r.raise_for_status()
user = r.json() # body already read
# aiohttp: async-only, response is a context manager
async with aiohttp.ClientSession(base_url="https://api.example.com",
timeout=aiohttp.ClientTimeout(total=10)) as session:
async with session.get("/users/1") as r:
r.raise_for_status()
user = await r.json() # body read inside the block
httpx reads the body by default and returns a complete response; streaming is opt-in with client.stream(...). aiohttp always streams and expects you to read inside the async with, which makes it harder to leak a connection by forgetting to read or close. httpx's sync Client with the same API is a real advantage for libraries that must support both worlds and for scripts migrating from requests.
Verify: port one call path to each client; pick the one your team reads more naturally, all else equal.
2. Measure throughput at your concurrency¶
The difference that can decide the choice only appears when one process has many requests in flight. Measure at your real concurrency:
async def bench_httpx(url: str, n: int, conc: int) -> float:
limits = httpx.Limits(max_connections=conc, max_keepalive_connections=conc)
async with httpx.AsyncClient(limits=limits) as client:
sem = asyncio.Semaphore(conc)
async def one():
async with sem:
(await client.get(url)).raise_for_status()
start = time.perf_counter()
await asyncio.gather(*(one() for _ in range(n)))
return n / (time.perf_counter() - start)
Against a 50 ms endpoint, the ideal is concurrency ÷ 0.05 s: 200 req/s at 10 and 2,000 at 100. aiohttp got 192 and 1,701. httpx got 186 and then 129 — adding concurrency made it slower, and time.process_time() showed why: the client process used 7.3 s of CPU for 1,000 requests at concurrency 100. Against a server with no artificial latency, the pattern was the same — httpx 2,969 → 1,683 → 619 req/s at concurrency 1, 10 and 100; aiohttp 6,840 → 16,638 → 20,348. The cost grows with the number of connections in httpcore's pool, so it is negligible at low concurrency and dominant at high. Re-measure on the versions you deploy; this is the kind of thing that gets fixed.
Verify: run the benchmark at your peak in-flight count with your actual versions; if httpx's CPU per request rises with concurrency, you are in the affected range.
3. Weigh the features¶
Throughput only matters if you are in the range where it differs. Features matter for everyone:
# HTTP/2 multiplexing: httpx only
async with httpx.AsyncClient(http2=True) as client:
r = await client.get("https://api.example.com/")
print(r.http_version) # "HTTP/2"
# WebSocket client: aiohttp built in
async with session.ws_connect("wss://stream.example.com/") as ws:
async for msg in ws:
handle(msg.data)
httpx brings HTTP/2 (useful against servers that limit connections, see HTTP/2 connection multiplexing with httpx), a sync client, trio support via anyio, an ASGI/WSGI transport for in-process testing, and transport-level retries. aiohttp brings a WebSocket client, a full HTTP server, and a long production history in high-throughput services. Ecosystems matter too: some SDKs are built on one or the other, and using the same client throughout avoids two pools and two sets of settings.
Verify: list the features your code needs today; if one is exclusive to one client, the choice is made.
4. Decide per process, not per company¶
The common split in practice:
# A FastAPI service calling a few internal APIs per request:
# concurrency per process is modest, httpx's API and testing tools win.
client = httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0))
# A crawler, fan-out gateway or webhook dispatcher with hundreds in flight:
# client throughput is the bottleneck, aiohttp wins.
session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(limit=500, limit_per_host=20))
A web service handling 50 concurrent requests, each making one or two upstream calls, rarely has more than a few dozen client requests in flight per process — the range where the two measured within a few percent. A crawler or fan-out service deliberately keeps hundreds in flight in one process, which is exactly where httpx's pool cost dominated. Using both in one codebase is reasonable when the workloads differ that much.
Verify: export in-flight client requests per process at peak; that number places you on the benchmark curve.
5. Keep the switch cheap¶
Whichever you choose, hide it behind a small interface so a later switch — or a version that fixes the scaling — is a one-file change:
from typing import Protocol
class Http(Protocol):
async def get_json(self, url: str, **params) -> dict: ...
class HttpxHttp:
def __init__(self, client: httpx.AsyncClient) -> None:
self.client = client
async def get_json(self, url: str, **params) -> dict:
r = await self.client.get(url, params=params)
r.raise_for_status()
return r.json()
class AiohttpHttp:
def __init__(self, session: aiohttp.ClientSession) -> None:
self.session = session
async def get_json(self, url: str, **params) -> dict:
async with self.session.get(url, params=params) as r:
r.raise_for_status()
return await r.json()
Map exceptions at the same boundary — httpx.TimeoutException and asyncio.TimeoutError from aiohttp, httpx.HTTPStatusError and aiohttp.ClientResponseError — so the rest of the code handles one set. Tests then mock the interface, or use respx and aioresponses at the transport level, as in mocking httpx calls in async tests with respx.
Verify: both implementations pass the same contract tests.
Verification¶
The client choice is sound when:
- It was benchmarked at the process's real in-flight concurrency, with deployed versions.
- Required features (HTTP/2, sync, WebSockets) are covered by the chosen client.
- Client CPU per request stays flat as concurrency rises.
- The client sits behind a small interface with mapped exceptions.
Diagnostic Hook: sample client-process CPU time per outbound request at different load levels. CPU per request that climbs with in-flight requests means the client library, not your code or the network, is the bottleneck — the pattern measured for httpx at concurrency 100.
Pitfalls & edge cases¶
- Benchmarking at concurrency 1. The clients differ mainly at high concurrency.
- Raising concurrency to fix httpx throughput. Measured, it made throughput worse.
- Mixing clients without a boundary. Two pools, two timeout models, two exception sets.
- Forgetting aiohttp's response context. Read the body inside
async with.
Frequently Asked Questions¶
Is aiohttp faster than httpx?
At high concurrency, in testing, by a large margin: 1,701 versus 129 requests per second at 100 concurrent requests, with httpx CPU-bound in its pool. At 10 concurrent requests they were within 3%.
Should I use httpx or aiohttp with FastAPI?
For typical services with modest upstream concurrency per process, httpx is a good fit for its API and testing tools. For high fan-out workloads, aiohttp scales better.
Does aiohttp support HTTP/2?
No. httpx supports HTTP/2 with the http2 extra; aiohttp speaks HTTP/1.1 only.
Why does httpx get slower with more concurrent requests?
In testing with httpx 0.28 and httpcore 1.0.9, client CPU per request rose sharply with the number of pooled connections, so throughput fell above a few dozen in flight. Measure with your versions.
Related¶
- Async HTTP Clients & Servers — up to the topic overview.
- Configuring httpx limits and pool timeouts — tuning the httpx pool you choose.
- Network I/O & Protocol Handling — the section overview.