Skip to content
Hrushiekesh Reddy Kanjula
A single glowing event loop hub with teal coroutine threads branching outward

Python Async Programming: asyncio, aiohttp, and When to Actually Use It

asyncio is not a magic speed-up — it's a precise tool for a specific problem. Here's what it actually does, when it wins, and when it costs you more than it's worth.

Published
February 2, 2026
Author
Hrushiekesh Kanjula Reddy
Read time
~5 min
Category
engineering

A single glowing event loop hub with teal coroutine threads branching outward

I avoided asyncio for a long time — not out of ignorance, but out of a reasonable suspicion that the complexity wasn't worth it. I had working code. A loop, requests, a list of URLs. It ran fine.

Then I ran it against 500 API endpoints. Synchronous version: four minutes. Async version with aiohttp: eighteen seconds. That's not a marginal improvement — that's a different category of tool.

The thing nobody tells you upfront is that async programming isn't about making Python faster. It's about making Python stop wasting time. Those are different problems with different solutions, and conflating them is how you end up with async code that runs slower than what you started with.

The Event Loop Is Just a Very Disciplined Scheduler

When people describe asyncio they tend to reach for restaurant metaphors, and honestly the metaphor earns its keep. A single chef can have five dishes in progress simultaneously — stirring one, waiting for another to boil, plating a third — without duplicating themselves. Nothing happens in actual parallel. The chef just doesn't stand idle while water heats.

That's the event loop — Python's internal task scheduler — doing cooperative multitasking. When your code hits an await, it's telling the event loop: "I'm blocked on something external. Go run something else." The event loop obliges, finds the next ready task, runs it until its next await, and so on. When the network call finally responds, your original task gets picked back up.

The crucial word there is cooperative. Unlike OS threads, which the kernel can preempt at any moment, async tasks yield control voluntarily. This is both a strength (no race conditions on shared state, no locks needed) and a trap (one blocking call freezes the whole event loop — more on that shortly).

A coroutine — defined with async def — is not a thread. It's a lightweight Python object, a suspended function with its own execution state. You can have tens of thousands of them in flight without the memory pressure that kills a thread-based approach (threads cost roughly 1 MB each on Linux; coroutines cost kilobytes).

aiohttp: Why the Standard Library Isn't Enough

Asyncio event loop scheduling aiohttp sessions across multiple concurrent HTTP connections

requests is the right tool for synchronous HTTP. It's also completely wrong inside an async function. When requests.get(url) blocks waiting for the server, it doesn't yield to the event loop — it freezes the entire thread. Every other coroutine waits. You've written async code that's slower than its synchronous equivalent because you've added overhead without gaining concurrency.

aiohttp is built around the event loop from day one. Every network operation yields properly. The pattern I reach for is straightforward:

import asyncio
import aiohttp
 
async def fetch(session: aiohttp.ClientSession, url: str) -> str:
    async with session.get(url) as response:
        return await response.text()
 
async def fetch_all(urls: list[str]) -> list[str]:
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        return await asyncio.gather(*tasks)
 
results = asyncio.run(fetch_all(urls))

Two things here that are consistently misunderstood. First: ClientSession should be created once and shared, not instantiated per request. The session manages a connection pool — a set of pre-established TCP connections — along with SSL handshakes and cookie state. Throwing it away after each request and recreating it is like hiring a taxi driver, firing them at the destination, and hiring a new one for the return trip. Reusing the session cuts per-request latency by 30–50% in practice.

Second: asyncio.gather schedules all coroutines at once and collects results when they're done. It's the async equivalent of a thread pool, without the thread overhead. At 300+ concurrent requests, aiohttp consistently benchmarks at 1.5–5× the throughput of a thread-based approach. At extreme concurrency — 5,000 simultaneous connections — the gap widens further.

When Async Is the Right Tool (and When It Isn't)

Hundreds of glowing amber data packets flowing simultaneously through a network — async throughput advantage

The question I ask before writing any async def is: what is the bottleneck?

If the bottleneck is waiting — network calls, database queries, file reads — async fills that wait with useful work. The speedup for I/O-heavy workloads is real: benchmarks on asyncio 3.13 with aiohttp 3.10 show 41% higher throughput (12,400 req/s vs 8,780 req/s) over equivalent synchronous code, with p99 latency dropping by 22%. For a scraper or a service making many outbound API calls, async is the right tool, full stop.

If the bottleneck is computation — parsing large datasets, image processing, numerical work — async adds overhead and gives you nothing. The CPU is busy; there's nothing to "fill" with concurrent work. Use multiprocessing or ProcessPoolExecutor instead, because those actually distribute work across CPU cores.

My practical rule: async for I/O, multiprocessing for CPU, threads when you need simplicity and the concurrency requirements are low. The mistake is treating async as a general-purpose speed-up when it's specifically a solution to the wasted-time-waiting problem.

The Traps You'll Walk Into

A forked glowing path in deep space — one branch clean and flowing, one tangled in amber loops — the tension of async complexity

Async code spreads. Once one function is async, every caller that wants to await it also has to be async. This propagates upward through your call stack with surprising speed. It's not a reason to avoid async, but it is a reason to make the architectural decision deliberately rather than starting with a "small async helper."

Error handling requires extra attention. By default, asyncio.gather re-raises the first exception it encounters and cancels the rest. If you want all results and all errors collected together — which is usually what you want in a scraper or batch job — pass return_exceptions=True.

The sneakiest trap: accidentally blocking the event loop. A single time.sleep(5) inside an async function freezes every coroutine waiting to run. The solution is await asyncio.sleep(5) instead. For third-party synchronous code you can't change, loop.run_in_executor offloads it to a thread pool so the event loop remains free. Missing one blocking call in a large async codebase is the debugging experience I'd least like to repeat.

Debugging is also legitimately harder. Stack traces in async code skip the frames where coroutines were suspended, so the call chain is less obvious. Python 3.11+ improved this significantly with better traceback annotations, but it's still not as clean as synchronous code.

The Point

Async Python is a well-designed solution to a specific problem: making efficient use of time your program would otherwise spend waiting. The benchmarks are clear. What surprised me was that the practical code surface is actually small — a handful of patterns covers most real use cases. The documentation front-loads a lot of theory before showing you anything useful, which is probably responsible for more avoidance than the complexity itself warrants.

If you're building something with meaningful I/O concurrency — a scraper, a service making many outbound calls, a WebSocket handler — async is worth the learning curve. If you're not, it isn't. That's the whole decision.

I've used these patterns extensively in the Assembly Hub, where async API calls to Mouser and DigiKey cut lookup times from minutes to seconds. The code lives there if you want to see it in context.