Skip to content

Async HTTP with aiohttp

The requests library is blocking.

For asyncio, you typically use:

  • aiohttp

It supports non-blocking HTTP requests.

aiohttp_get.py
import asyncio
import aiohttp
 
 
async def main():
    timeout = aiohttp.ClientTimeout(total=10)
 
    async with aiohttp.ClientSession(timeout=timeout) as session:
        async with session.get("https://api.github.com") as resp:
            data = await resp.json()
            print(resp.status)
            print(list(data.keys())[:5])
 
 
asyncio.run(main())
aiohttp_many.py
import asyncio
import aiohttp
 
 
async def fetch(session: aiohttp.ClientSession, url: str) -> int:
    async with session.get(url) as resp:
        await resp.read()
        return resp.status
 
 
async def main():
    urls = [
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/1",
    ]
 
    timeout = aiohttp.ClientTimeout(total=5)
 
    async with aiohttp.ClientSession(timeout=timeout) as session:
        statuses = await asyncio.gather(*(fetch(session, u) for u in urls), return_exceptions=True)
        print(statuses)
 
 
asyncio.run(main())
  • Use timeouts.
  • Handle non-200 responses.
  • Avoid hitting API rate limits (use semaphores).

A request spends nearly all its life doing nothing — the bytes are in flight, and the CPU is idle. That waiting is exactly what an event loop can overlap:

diagram Diagram mermaid

Measured against a local server whose endpoint sleeps 0.20 s, 12 requests:

approachtimespeedup
requests.get in a loop2.53 s1.00×
requests.Session in a loop2.47 s1.03×
aiohttp + gather0.22 s11.6×

A local server was used deliberately: the delay is known and fixed, so the figures say something about the client rather than about someone else’s network.

Note the middle row. Reusing a Session saves the TCP and TLS handshake, which is real on a remote host but nearly free on localhost — 1.03× here. Connection reuse is not what buys the 11.6×; overlapping the waiting is.

fetch_all.py
import asyncio, aiohttp
 
async def fetch(session, url):
    async with session.get(url) as resp:      # releases the loop while waiting
        return resp.status, await resp.json()
 
async def main(urls):
    async with aiohttp.ClientSession() as session:   # ONE session for all requests
        return await asyncio.gather(*[fetch(session, u) for u in urls])
 
if __name__ == "__main__":
    results = asyncio.run(main([f"http://localhost:8000/slow/{i}" for i in range(12)]))

Two details carry most of the benefit:

  • One ClientSession for the whole batch. It owns the connection pool. Creating one per request throws that away and is the most common aiohttp mistake.
  • async with on the response. It releases the connection back to the pool. Without it the pool drains and later requests queue behind nothing.

Twelve requests, a server that takes 0.20 s each. Drag the concurrency limit and watch the batches form — the total is simply how many rounds you need.

sketch Concurrency limit sets the number of rounds p5.js
With 12 requests at 0.2s each, a limit of 3 needs 4 rounds and a limit of 6 needs 2. Measured times match that arithmetic almost exactly.

Measured against the local server:

limitrounds neededpredictedmeasured
Semaphore(3)40.80 s0.83 s
Semaphore(6)20.40 s0.42 s
no limit (12)10.20 s0.22 s

The arithmetic is the whole model: time ≈ ceil(N / limit) × latency. Overhead is roughly 20–30 ms on top.

limited.py
async def fetch_all(urls, limit=6):
    sem = asyncio.Semaphore(limit)
    async with aiohttp.ClientSession() as session:
        async def one(url):
            async with sem:                       # at most `limit` inside at a time
                async with session.get(url) as r:
                    return r.status, await r.json()
        return await asyncio.gather(*[one(u) for u in urls])
pch.quizTag pch.quizDefaultTitle
  1. 12 requests to an endpoint that takes 0.2 s: a requests loop took 2.53 s and aiohttp with gather took 0.22 s. Where does the gain come from?

    pch.quizShowAnswer

    B — the waiting overlaps, since each request releases the loop while its bytes are in flight — Almost all of a request is waiting. One thread can hold twelve waits at once, so the total approaches a single round trip rather than twelve.

  2. Reusing requests.Session for the same 12 local requests gave only 1.03x. Why so little?

    pch.quizShowAnswer

    B — a Session helps by skipping TCP and TLS setup, which is nearly free over localhost — Connection reuse removes handshake cost, which matters over a real network and barely registers locally. It is not what produces the 11.6x — overlapping the waiting is.

  3. With 12 URLs at 0.2 s each and asyncio.Semaphore(3), roughly how long should the batch take?

    pch.quizShowAnswer

    B — about 0.8 s, because 12 / 3 gives 4 sequential rounds — A semaphore of 3 permits three in flight at a time, so the work forms 4 rounds of 0.2 s. Predicted 0.80 s, measured 0.83 s.

  4. Why create one aiohttp.ClientSession for a whole batch rather than one per request?

    pch.quizShowAnswer

    B — the session owns the connection pool, so a session per request discards connection reuse and setup cost — A ClientSession holds the connector and its pool. Building one per request recreates that machinery every time and is the most common aiohttp mistake.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading