Python concurrent HTTP requests
last modified October 30, 2026 Python 3.15
In this article we show how to send concurrent HTTP requests in Python.
The Hypertext Transfer Protocol (HTTP) is an application protocol for distributed, collaborative, hypermedia information systems. Clients and servers communicate by exchanging messages: the client sends a request and the server answers with a response.
Concurrent requests
Requests can be handled sequentially or concurrently. Sequential requests are sent one by one; each request waits for the previous one to finish. Most of that time is spent waiting for the network, so the total time is roughly the sum of all response times. With concurrent requests, the waiting overlaps and the total time approaches the time of the slowest request.
There are two basic ways to send concurrent requests in Python: multiple
threads or asynchronous programming. In the multi-threaded approach, each
request runs in its own thread, typically from a
ThreadPoolExecutor. In asynchronous programming, there is one
thread and an event loop that switches between coroutines whenever one of them
waits for I/O. Async programming requires libraries built for it, such as
httpx or aiohttp.
The examples use the https://httpbin.org/delay/n endpoint, which
responds after n seconds. We request five URLs with delays of 2, 1,
3, 1, and 2 seconds, nine seconds in total. Network latency adds to these
times, so measured times vary from run to run.
We install the three HTTP libraries used in the examples.
$ pip install requests httpx aiohttp
Sequential requests
In the first example, we send the requests one after another. To measure the
elapsed time, we use the perf_counter function.
import time
import requests
urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]
start = time.perf_counter()
with requests.Session() as session:
for url in urls:
resp = session.get(url, timeout=10)
print(resp.status_code, url)
elapsed = time.perf_counter() - start
print(f'Total time elapsed: {elapsed:.2f} seconds')
We use the requests library.
with requests.Session() as session:
for url in urls:
resp = session.get(url, timeout=10)
A Session reuses the TCP connection to the same host, which
saves the connection setup for every request after the first. The
timeout parameter prevents a request from hanging forever.
The total time is the sum of the delays plus the network overhead of each request.
$ python mul_sync.py 200 https://httpbin.org/delay/2 200 https://httpbin.org/delay/1 200 https://httpbin.org/delay/3 200 https://httpbin.org/delay/1 200 https://httpbin.org/delay/2 Total time elapsed: 11.05 seconds
Threads with executor.map
A ThreadPoolExecutor from the concurrent.futures
module runs functions in a pool of threads. Its map method works
like the built-in map, but calls the function concurrently.
import time
from concurrent.futures import ThreadPoolExecutor
import requests
urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]
def get_status(url):
resp = requests.get(url, timeout=10)
return resp.status_code
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as executor:
for url, status in zip(urls, executor.map(get_status, urls)):
print(status, url)
elapsed = time.perf_counter() - start
print(f'Total time elapsed: {elapsed:.2f} seconds')
We get the status codes of the URLs with five threads.
with ThreadPoolExecutor(max_workers=5) as executor:
for url, status in zip(urls, executor.map(get_status, urls)):
The map method returns the results in the order of the input,
regardless of which request finished first. The with statement
waits for all threads to finish.
The total time is now close to the longest delay of three seconds.
$ python threaded_map.py 200 https://httpbin.org/delay/2 200 https://httpbin.org/delay/1 200 https://httpbin.org/delay/3 200 https://httpbin.org/delay/1 200 https://httpbin.org/delay/2 Total time elapsed: 3.79 seconds
Threads with as_completed
To process results as soon as they arrive, we submit the calls individually
and iterate over the futures with as_completed.
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]
def get_status(url):
resp = requests.get(url, timeout=10)
return resp.status_code
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {executor.submit(get_status, url): url for url in urls}
for future in as_completed(futures):
url = futures[future]
elapsed = time.perf_counter() - start
print(f'{elapsed:5.2f}s {future.result()} {url}')
elapsed = time.perf_counter() - start
print(f'Total time elapsed: {elapsed:.2f} seconds')
We print each result with the time at which it became available.
futures = {executor.submit(get_status, url): url for url in urls}
The submit method schedules a call and returns a
Future. We map each future to its URL so that we know which
request a result belongs to.
for future in as_completed(futures):
url = futures[future]
...
print(f'{elapsed:5.2f}s {future.result()} {url}')
The as_completed function yields futures in the order they
finish. The result method returns the value of the call or
re-raises its exception.
$ python threaded.py 1.73s 200 https://httpbin.org/delay/1 1.73s 200 https://httpbin.org/delay/1 2.75s 200 https://httpbin.org/delay/2 2.75s 200 https://httpbin.org/delay/2 3.77s 200 https://httpbin.org/delay/3 Total time elapsed: 3.78 seconds
Async requests with httpx
The httpx library provides an asynchronous client. Coroutines
are run concurrently with asyncio.gather.
import asyncio
import time
import httpx
urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]
async def main():
async with httpx.AsyncClient(timeout=10) as client:
resps = await asyncio.gather(*(client.get(url) for url in urls))
for resp in resps:
print(resp.status_code, resp.url)
start = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - start
print(f'Total time elapsed: {elapsed:.2f} seconds')
We send all requests from a single thread.
async with httpx.AsyncClient(timeout=10) as client:
We create one client and share it among all requests. The client keeps a pool of connections; creating a new client for each request would throw this benefit away.
resps = await asyncio.gather(*(client.get(url) for url in urls))
Each client.get call returns a coroutine. The
gather function runs them concurrently and returns their results
in the order of the arguments.
$ python mul_async.py 200 https://httpbin.org/delay/2 200 https://httpbin.org/delay/1 200 https://httpbin.org/delay/3 200 https://httpbin.org/delay/1 200 https://httpbin.org/delay/2 Total time elapsed: 3.76 seconds
One client for all requests
A common mistake is to create a new client inside the coroutine that sends a request:
async def get_async(url):
async with httpx.AsyncClient() as client:
return await client.get(url)
resps = await asyncio.gather(*map(get_async, urls))
This code works and returns correct results, but it is wasteful. A client is not just a function for sending requests; it is an object with state:
- a connection pool; after a response, the TCP and TLS connection stays open (HTTP keep-alive) and the next request to the same host reuses it, skipping the TCP handshake and the TLS negotiation
- an SSL context with the loaded CA certificates
- connection limits, by default at most 100 connections per client
- shared settings such as timeouts, headers, cookies, and authentication
When each request gets its own client, the pool is thrown away after a single request, the certificates are loaded again for every client, and there is no global limit on the number of open connections. With a thousand URLs, the program would open a thousand connections at once, which can exhaust file descriptors or get the client blocked by the server. The next example measures the difference.
import asyncio
import logging
import time
import httpx
url = 'https://example.com'
class ConnectionCounter(logging.Handler):
def __init__(self):
super().__init__()
self.count = 0
def emit(self, record):
if record.getMessage().startswith('connect_tcp.complete'):
self.count += 1
counter = ConnectionCounter()
logger = logging.getLogger('httpcore.connection')
logger.setLevel(logging.DEBUG)
logger.addHandler(counter)
async def fetch_with_new_client(url):
async with httpx.AsyncClient() as client:
return await client.get(url)
async def client_per_request(waves, size):
for _ in range(waves):
await asyncio.gather(*(fetch_with_new_client(url) for _ in range(size)))
async def shared_client(waves, size):
async with httpx.AsyncClient() as client:
for _ in range(waves):
await asyncio.gather(*(client.get(url) for _ in range(size)))
for waves, size in [(1, 20), (4, 5)]:
for strategy in (client_per_request, shared_client):
counter.count = 0
start = time.perf_counter()
asyncio.run(strategy(waves, size))
elapsed = time.perf_counter() - start
print(f'{strategy.__name__:<18} {waves} x {size:<2} {elapsed:4.2f}s '
f'{counter.count:>2} connections')
We send twenty requests to the same host with both approaches and count the TCP connections that are opened.
class ConnectionCounter(logging.Handler):
...
def emit(self, record):
if record.getMessage().startswith('connect_tcp.complete'):
self.count += 1
httpx uses the httpcore library for the transport, which logs
every new TCP connection at the DEBUG level. Our logging handler
counts these messages instead of printing them.
async def fetch_with_new_client(url):
async with httpx.AsyncClient() as client:
return await client.get(url)
This is the wasteful pattern: a client is created, used for one request, and closed together with its connection.
async def shared_client(waves, size):
async with httpx.AsyncClient() as client:
for _ in range(waves):
await asyncio.gather(*(client.get(url) for _ in range(size)))
Here one client serves all requests. Its connections stay in the pool between the waves.
for waves, size in [(1, 20), (4, 5)]:
We run two scenarios: one burst of twenty requests, and four waves of five requests, where each wave waits for the previous one to finish.
In a single burst, both approaches open twenty connections. All requests start at the same moment, so there is no idle connection to reuse yet; the times are similar and vary from run to run with the network. In the four waves, the shared client opens five connections in the first wave and reuses them in the next three. The client-per-request version opens a new connection for every request and takes about twice as long. The more requests a program sends to the same host over time, such as calls to one API or pages of one website, the larger the difference.
$ python client_reuse.py client_per_request 1 x 20 0.44s 20 connections shared_client 1 x 20 0.33s 20 connections client_per_request 4 x 5 0.85s 20 connections shared_client 4 x 5 0.40s 5 connections
Async requests with aiohttp
The aiohttp library is another popular asynchronous HTTP client.
import asyncio
import time
import aiohttp
urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]
async def fetch(session, url):
async with session.get(url) as resp:
data = await resp.json()
return resp.status, data['url']
async def main():
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
results = await asyncio.gather(*(fetch(session, url) for url in urls))
for status, url in results:
print(status, url)
start = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - start
print(f'Total time elapsed: {elapsed:.2f} seconds')
We read the status and the JSON body of each response.
async def fetch(session, url):
async with session.get(url) as resp:
data = await resp.json()
return resp.status, data['url']
In aiohttp, the response is a context manager and the body is read with
await. Leaving the block releases the connection back to the
pool.
timeout = aiohttp.ClientTimeout(total=10)
Timeouts are configured with a ClientTimeout object.
$ python aiohttp_async.py 200 https://httpbin.org/delay/2 200 https://httpbin.org/delay/1 200 https://httpbin.org/delay/3 200 https://httpbin.org/delay/1 200 https://httpbin.org/delay/2 Total time elapsed: 3.83 seconds
Async results as they complete
The asyncio.as_completed function is the async counterpart of
concurrent.futures.as_completed. Since Python 3.13, it can be
used with async for.
import asyncio
import time
import httpx
urls = [f'https://httpbin.org/delay/{d}' for d in (2, 1, 3, 1, 2)]
async def main():
start = time.perf_counter()
async with httpx.AsyncClient(timeout=10) as client:
tasks = [asyncio.create_task(client.get(url)) for url in urls]
async for task in asyncio.as_completed(tasks):
resp = await task
elapsed = time.perf_counter() - start
print(f'{elapsed:5.2f}s {resp.status_code} {resp.url}')
asyncio.run(main())
We print each response when it arrives.
tasks = [asyncio.create_task(client.get(url)) for url in urls]
async for task in asyncio.as_completed(tasks):
resp = await task
With async for, the loop yields the original task objects in the
order of completion. The fastest responses are processed first.
$ python async_completed.py 1.58s 200 https://httpbin.org/delay/1 1.59s 200 https://httpbin.org/delay/1 2.73s 200 https://httpbin.org/delay/2 2.73s 200 https://httpbin.org/delay/2 3.57s 200 https://httpbin.org/delay/3
Limiting concurrency
Sending hundreds of requests at once can overload a server or get the client
blocked. An asyncio.Semaphore limits the number of requests in
flight.
import asyncio
import time
import httpx
urls = [f'https://httpbin.org/delay/1?id={i}' for i in range(1, 7)]
async def fetch(client, sem, url, start):
async with sem:
begin = time.perf_counter() - start
resp = await client.get(url)
end = time.perf_counter() - start
print(f'{resp.url.params["id"]}: {begin:4.2f}s - {end:4.2f}s')
return resp.status_code
async def main():
sem = asyncio.Semaphore(2)
start = time.perf_counter()
async with httpx.AsyncClient(timeout=10) as client:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(client, sem, url, start)) for url in urls]
print([t.result() for t in tasks])
print(f'Total time elapsed: {time.perf_counter() - start:.2f} seconds')
asyncio.run(main())
We send six requests, but at most two at a time.
async with sem:
begin = time.perf_counter() - start
resp = await client.get(url)
A task waits at async with sem until one of the two slots is
free. The output shows the requests running in three waves.
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(client, sem, url, start)) for url in urls]
A TaskGroup waits for all tasks to finish. If one of them fails,
it cancels the others and raises an ExceptionGroup.
$ python semaphore.py 1: 0.02s - 1.55s 2: 0.03s - 1.57s 3: 1.55s - 2.72s 4: 1.57s - 2.74s 5: 2.72s - 3.89s 6: 2.74s - 3.92s [200, 200, 200, 200, 200, 200] Total time elapsed: 3.92 seconds
Handling errors
Concurrent requests fail independently. Handling errors inside each coroutine keeps one failure from affecting the other results.
import asyncio
import httpx
urls = [
'https://example.com',
'https://httpbin.org/status/404',
'https://httpbin.org/delay/5',
'https://no-such-host.invalid',
]
async def check(client, url):
try:
resp = await client.get(url)
resp.raise_for_status()
return f'OK {resp.status_code}'
except httpx.HTTPStatusError as e:
return f'HTTP error {e.response.status_code}'
except httpx.TimeoutException:
return 'timed out'
except httpx.ConnectError as e:
return f'connection failed: {type(e).__name__}'
async def main():
async with httpx.AsyncClient(timeout=2) as client:
results = await asyncio.gather(*(check(client, url) for url in urls))
for url, result in zip(urls, results):
print(f'{url:32} {result}')
asyncio.run(main())
We check four URLs: one works, one returns 404, one is too slow, and one has a host name that does not exist.
resp = await client.get(url) resp.raise_for_status()
httpx does not treat 4xx and 5xx responses as errors; the
raise_for_status method turns them into an
HTTPStatusError.
except httpx.TimeoutException:
return 'timed out'
except httpx.ConnectError as e:
return f'connection failed: {type(e).__name__}'
The two-second client timeout cuts off the five-second delay. A host name that
cannot be resolved raises a ConnectError.
$ python errors.py https://example.com OK 200 https://httpbin.org/status/404 HTTP error 404 https://httpbin.org/delay/5 timed out https://no-such-host.invalid connection failed: ConnectError
First response wins
Sometimes we need only the fastest of several responses, for example when the
same file is available on several mirrors. Python 3.15 adds
TaskGroup.cancel, which cancels all remaining tasks of a group
and lets the async with block exit normally.
import asyncio
import time
import httpx
mirrors = {
'mirror-a': 'https://httpbin.org/delay/3',
'mirror-b': 'https://httpbin.org/delay/1',
'mirror-c': 'https://httpbin.org/delay/2',
}
async def fetch(client, tg, name, url, result):
try:
resp = await client.get(url)
except asyncio.CancelledError:
print(f'{name} cancelled')
raise
result['winner'] = name, resp.status_code
tg.cancel()
async def main():
result = {}
start = time.perf_counter()
async with httpx.AsyncClient(timeout=10) as client:
async with asyncio.TaskGroup() as tg:
for name, url in mirrors.items():
tg.create_task(fetch(client, tg, name, url, result))
print('winner:', result['winner'])
print(f'Total time elapsed: {time.perf_counter() - start:.2f} seconds')
asyncio.run(main())
We request the same resource from three mirrors and keep the first response.
result['winner'] = name, resp.status_code tg.cancel()
The first task to finish stores its result and cancels the group. The other
tasks receive a CancelledError at their await; they
print a message and re-raise it, as cancelled tasks should.
async with asyncio.TaskGroup() as tg:
for name, url in mirrors.items():
tg.create_task(fetch(client, tg, name, url, result))
Cancelling with tg.cancel does not raise an exception out of the
async with block. The program finishes in about the time of the
fastest mirror. More on the method is in
Python async/await.
$ python first_wins.py
mirror-a cancelled
mirror-c cancelled
winner: ('mirror-b', 200)
Total time elapsed: 1.62 seconds
Source
Python concurrent execution - documentation
In this article we have shown how to send concurrent HTTP requests in Python.
Author
List all Python tutorials.