Back to Blog

HTTPX vs Requests vs aiohttp: Features, Performance, and Python Use Cases

Chloe Sun

Aug 28, 2026 · Comparisons · 9 min read

Requests is the best default for straightforward synchronous Python scripts. HTTPX is the closest modern alternative when a project needs both synchronous and asynchronous APIs or HTTP/2. aiohttp is strongest in an async-first application that also benefits from native WebSockets, connector controls, or its server framework. None is universally fastest: latency, concurrency, connection reuse, protocol support, and the surrounding application matter more than a library name.

This HTTPX vs Requests vs aiohttp comparison uses runnable code and controlled local measurements. Every example was tested on Windows 11 with CPython 3.12.13, Requests 2.34.2, HTTPX 0.28.1, and aiohttp 3.14.3 on August 27, 2026.

TL;DR: Use Requests for simple synchronous scripts and established integrations. Use HTTPX when one project needs both sync and async APIs, or when HTTP/2 is required and verified. Use aiohttp for async-first applications that benefit from native WebSockets, detailed connector controls, or its server framework. The local benchmark figures below are environment-specific, so validate performance against your own target service, concurrency, and proxy route.

HTTPX vs Requests vs aiohttp at a Glance

Criterion Requests HTTPX aiohttp
I/O model Synchronous Synchronous and asynchronous Asynchronous
Reusable client Session Client / AsyncClient ClientSession
HTTP/2 client No Optional and opt-in Not in the tested stable client
WebSocket client Third-party Third-party Built in
HTTP server No No Built in
Learning curve Low Low to medium Medium to high
Best fit Scripts and existing sync apps Modern SDKs and mixed architectures Async services and high-concurrency crawlers

Choose Requests when the call flow is linear and maintainability matters most. Choose HTTPX when a Requests-like API must work in sync and async code, or when verified HTTP/2 support is required. Choose aiohttp when asyncio already defines the application architecture and its broader framework features are useful.

Do not add async solely because a script makes several requests. Async I/O improves how one thread handles waiting sockets; it does not accelerate CPU-heavy parsing, bypass server limits, or make an inefficient request strategy safe.

Run the Same Request With All Three Clients

The following dependency-free server makes the examples reproducible without relying on a public test service. Save it as local_json_server.py, then run it in one terminal.

import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_GET(self):
        client = self.headers.get("X-Client-Name", "unknown")
        body = json.dumps({"message": "test passed", "client": client}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format, *args):
        pass

ThreadingHTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

Install the tested clients and start the server:

python -m pip install requests==2.34.2 "httpx[http2]==0.28.1" aiohttp==3.14.3
python local_json_server.py

Requests

import requests

with requests.Session() as session:
    response = session.get(
        "http://127.0.0.1:8765/json",
        headers={"X-Client-Name": "requests"},
        timeout=5,
    )
    response.raise_for_status()
    print(response.status_code, response.json())

A Requests Session persists cookies and shared settings while allowing urllib3’s connection pool to reuse connections to the same origin. It remains synchronous: each request blocks its current thread.

HTTPX

import httpx

with httpx.Client(timeout=5) as client:
    response = client.get(
        "http://127.0.0.1:8765/json",
        headers={"X-Client-Name": "httpx"},
    )
    response.raise_for_status()
    print(response.status_code, response.http_version, response.json())

HTTPX deliberately feels familiar to Requests users. The httpx.Response object also exposes http_version, which verifies whether HTTP/2 was actually negotiated.

aiohttp

import asyncio
import aiohttp

async def main():
    timeout = aiohttp.ClientTimeout(total=5)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        async with session.get(
            "http://127.0.0.1:8765/json",
            headers={"X-Client-Name": "aiohttp"},
        ) as response:
            response.raise_for_status()
            print(response.status, await response.json())

asyncio.run(main())

aiohttp body methods such as .json(), .text(), and .read() are awaitable. Its official Client Quickstart recommends reusing a ClientSession because the session owns the connection pool.

Requests, HTTPX, and aiohttp returning the same local JSON response
All three clients returned HTTP 200 from the same deterministic local endpoint.

Sessions, Responses, and Connection Reuse

Top-level calls such as requests.get() and httpx.get() are convenient for one request. Repeated traffic should use a reusable client. Requests documents cookie persistence and pooling in its advanced usage guide.

Task Requests HTTPX aiohttp
Reuse connections Session Client / AsyncClient ClientSession
Status .status_code .status_code .status
Text .text .text await .text()
JSON .json() .json() await .json()
Bytes .content .content await .read()
Cleanup with with / async with async with

Even with httpx.AsyncClient, .json() and .text are synchronous after a normal awaited request because the body has already been read. For large files, use each client’s streaming interface rather than materializing the complete response.

An HTTP client session is not the same as a proxy sticky session. The client session owns sockets, cookies, and shared configuration. A proxy session controls whether multiple requests retain the same exit IP. Both may need deliberate reuse in a scraping or monitoring workflow.

Synchronous vs Asynchronous Concurrency

Requests and httpx.Client block their threads while waiting. HTTPX AsyncClient and aiohttp return control to the event loop, allowing other sockets to progress without one thread per request.

Bound concurrency at both the task and connection-pool layers:

import asyncio
import httpx

async def fetch_many(urls: list[str]) -> list[int]:
    semaphore = asyncio.Semaphore(10)
    limits = httpx.Limits(max_connections=10, max_keepalive_connections=10)
    async with httpx.AsyncClient(timeout=10, limits=limits) as client:
        async def fetch(url: str) -> int:
            async with semaphore:
                response = await client.get(url)
                response.raise_for_status()
                return response.status_code
        return await asyncio.gather(*(fetch(url) for url in urls))

The semaphore limits tasks entering the request block; the pool limits open connections. Creating thousands of tasks can exhaust file descriptors, fill a pool, increase proxy handshakes, or trigger HTTP 429. Async improves waiting efficiency but does not override rate limits.

Feature-by-Feature Comparison

Feature Requests 2.34.2 HTTPX 0.28.1 aiohttp 3.14.3
HTTP/1.1 Yes Yes Yes
HTTP/2 No Optional extra No released stable client support tested
Default timeout None 5 seconds of network inactivity 300 seconds total
GET redirects Followed Off unless enabled Followed
Automatic status retry No No No
Streaming Yes Yes Yes
HTTP proxy Yes Yes Yes
SOCKS Optional dependency Optional dependency Usually third-party
WebSocket Third-party Third-party Built in

Three differences cause frequent production bugs. Requests has no default request timeout, so every production call should set one; this Python Requests timeout guide explains connect and read timeouts. HTTPX does not follow redirects by default. None of the clients automatically retries arbitrary 429 or 5xx responses with safe exponential backoff.

HTTPX requires the httpx[http2] extra and http2=True. The server must negotiate HTTP/2; check response.http_version instead of assuming it worked. The official HTTPX HTTP/2 documentation describes the opt-in behavior.

HTTPX vs Requests vs aiohttp Performance Benchmark

The benchmark used a separate local HTTP/1.1 server process, complete-body validation, ten warm-up requests, seven isolated measured runs per case, randomized client order, and matched connection limits. Requests concurrency used ThreadPoolExecutor with thread-local Sessions; HTTPX and aiohttp used one async client with both a semaphore and a connection limit. The figures are a local benchmark snapshot, not a vendor performance claim. Publish the benchmark harness, raw runs, and aggregation method with this article before treating the numbers as independently reproducible evidence.

Workload and mode Requests req/s HTTPX req/s aiohttp req/s
20 ms / 1 KB, 100 sequential 45.84 46.72 47.28
20 ms / 1 KB, 100 requests, concurrency 10 413.86 383.82 468.09
200 ms / 100 KB, 30 sequential 4.94 4.95 4.96
200 ms / 100 KB, 100 requests, concurrency 10 48.88 48.44 49.59
200 ms / 100 KB, 500 requests, concurrency 50 239.97 162.96 245.35

All listed requests completed without errors. Sequential results were effectively tied because server delay dominated client overhead. aiohttp led the selected concurrent cases on this Windows machine, but these figures are not a universal ranking. Public DNS, TLS, packet loss, proxies, throttling, parsing, and operating-system event loops can change the result.

Verified terminal output from the local Python HTTP client benchmark

The image is generated from measured run data, with the methodology stated directly on the visual.

Why the Extreme HTTPX Result Is Not a General Conclusion

In a separate stress case—500 one-kilobyte responses, 20 ms delay, concurrency 50—Requests measured 1,420.18 req/s, HTTPX 156.13 req/s, and aiohttp 2,136.59 req/s. The HTTPX result repeated across seven runs even though the AsyncClient was reused and both max_connections and max_keepalive_connections were 50.

That does not prove HTTPX is nine times slower in real applications. The loopback workload removes DNS, TLS, routing, and remote-server variance while emphasizing tiny-operation scheduling on one Windows event loop. No comparable Linux or remote-service run was performed, so the result is an environment-specific anomaly and is excluded from the main decision logic. If high concurrency is critical, reproduce the workload on the intended operating system and target service.

Separate HTTPX HTTP/1.1 vs HTTP/2 Test

Against the same local TLS server with ALPN, HTTPX handled 100 requests at concurrency 20. HTTP/1.1 reached 214.94 req/s with a 190.82 ms P95; HTTP/2 reached 470.97 req/s with a 58.77 ms P95. This is a within-client protocol test and must not be compared directly with the three-client HTTP/1.1 benchmark.

Benchmark charts separating client and HTTPX protocol comparisons

The bottom chart isolates HTTPX protocol behavior so it is not mistaken for a three-client comparison.

Safe Retries for 429 and 5xx Responses

Retry only failures that may be temporary, cap attempts, honor Retry-After, and avoid automatically replaying non-idempotent operations. This tested HTTPX pattern handles GET requests without hiding permanent 4xx errors:

import asyncio
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import httpx

RETRYABLE = {429, 502, 503, 504}

def delay_for(response, attempt):
    value = response.headers.get("Retry-After")
    if value:
        try:
            return max(0.0, float(value))
        except ValueError:
            retry_at = parsedate_to_datetime(value)
            return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
    return min(0.5 * (2 ** attempt), 8.0)

async def get_with_retry(client, url, attempts=4):
    for attempt in range(attempts):
        response = await client.get(url)
        if response.status_code not in RETRYABLE:
            response.raise_for_status()
            return response
        if attempt == attempts - 1:
            response.raise_for_status()
        await asyncio.sleep(delay_for(response, attempt))

Retries and concurrency controls solve different problems. Reducing concurrency prevents avoidable pressure; backoff determines when a failed attempt may be repeated. Proxy rotation should not be used to evade an explicit rate limit.

Using Proxies With Requests, HTTPX, and aiohttp

All three clients support authenticated HTTP proxies. Keep credentials in environment variables and percent-encode them before constructing a URL.

Use proxies only for targets and data you are authorized to access. Follow applicable law, the destination’s terms, rate limits, and Retry-After responses; changing HTTP clients or proxy routes does not remove those obligations.

import asyncio, os
from urllib.parse import quote, urlsplit, urlunsplit
import aiohttp, httpx, requests

gateway = os.environ["PROXY_GATEWAY"]  # http://gateway:port
username = quote(os.environ["PROXY_USERNAME"], safe="")
password = quote(os.environ["PROXY_PASSWORD"], safe="")
parts = urlsplit(gateway)
host = f"{parts.hostname}:{parts.port}"
proxy_url = urlunsplit((parts.scheme, f"{username}:{password}@{host}", "", "", ""))
target = "https://example.com/"

with requests.Session() as session:
    r = session.get(target, proxies={"http": proxy_url, "https": proxy_url}, timeout=10)
    r.raise_for_status()

with httpx.Client(proxy=proxy_url, timeout=10) as client:
    r = client.get(target)
    r.raise_for_status()

async def via_aiohttp():
    timeout = aiohttp.ClientTimeout(total=10)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        async with session.get(target, proxy=proxy_url) as r:
            r.raise_for_status()
            print(r.status)

asyncio.run(via_aiohttp())

Authenticated proxy verification with Requests, HTTPX, and aiohttp

The three clients reached the same local verification endpoint through an authenticated proxy; no commercial credentials or public IP appear in the screenshot.

A 407 response means the proxy rejected authentication. A 403 usually comes from the destination. A 429 indicates rate limiting. Switching clients cannot make those conditions equivalent.

For authorized scraping, monitoring, or regional verification, Rola IP supplies the network layer while the HTTP client manages connections and responses. The web scraping proxy page outlines a relevant authorized collection scenario. The Python proxy integration guide covers connection syntax, while proxy parameters control location, rotation, and session behavior.

Client and proxy settings should be planned together. Reusing an HTTP client reduces repeated handshakes. A sticky proxy session keeps an exit IP stable for pagination or a multi-step check; rotation suits independent authorized requests. Geographic accuracy, session stability, and responsible pacing often matter more than a small local throughput difference.

Common Errors and How to Fix Them

Symptom Likely cause Fix
Request appears to hang Missing or excessive timeout Set explicit connect/read or total timeouts
Unclosed client session aiohttp session not closed Use async with ClientSession()
RuntimeError: no running event loop Async object created outside its loop Create it inside an async function
HTTPX returns 301 while Requests returns 200 Different redirect defaults Set follow_redirects=True when intended
Pool timeout or too many files Task count exceeds pool or OS limits Bound tasks and reuse one client
HTTP 429 Request rate too high Honor Retry-After, reduce concurrency, and back off
Proxy 407 Invalid proxy authentication Check encoded credentials and gateway
Memory rises during downloads Entire body buffered Stream chunks and close responses

Conclusion: Which Python HTTP Client Should You Choose?

Keep Requests for clear synchronous work and mature integrations. Choose HTTPX for the most balanced migration path, a shared sync/async design, or HTTP/2. Choose aiohttp when an async-first architecture, native WebSockets, or its server framework justifies the additional lifecycle complexity.

Do not migrate for novelty or select a client from a synthetic leaderboard. Test the intended server, response size, operating system, concurrency, and proxy route. In production, client reuse, explicit timeouts, bounded concurrency, safe retries, and responsible pacing usually matter more than small library-level speed differences.

Frequently asked questions