Expert-level performance work means knowing which layer costs what and how the event loop behaves under load.
aiohttp's speed comes from a few components:
- HTTP parsing uses a C extension built on llhttp (Node.js's parser), with a pure-Python fallback. Setting
AIOHTTP_NO_EXTENSIONS=1forces the Python implementation, useful when debugging but much slower. - multidict provides the case-insensitive, multi-value header and query containers, also in C.
- yarl implements the immutable
URLtype; building URLs with yarl (URL(base) / "path" % {"q": 1}) avoids string bugs. - frozenlist, aiosignal and propcache support signals and cached properties.
Each process runs one event loop on one thread. Throughput depends on never blocking it:
- Any synchronous call that takes milliseconds (a blocking database driver,
json.dumpsof a huge object, image processing,time.sleep) delays every connection. Offload withawait asyncio.to_thread(fn, ...)or a process pool. - Run with
PYTHONASYNCIODEBUG=1orloop.set_debug(True): asyncio logs callbacks that take longer thanloop.slow_callback_duration(100 ms by default). - Profile with
py-spyagainst the live process, oryappifor coroutine-aware profiling.
Other levers: uvloop as the event loop, orjson for JSON (json_response(dumps=...) and resp.json(loads=...) accept custom functions), connection reuse, and response streaming (web.StreamResponse) for large payloads instead of building them in memory. Scale across cores by running several processes, since one loop uses one core.
Example
import asyncio
import time
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
def blocking_work():
time.sleep(0.2) # stands in for a sync DB call or heavy CPU
return "done"
async def bad(request):
return web.Response(text=blocking_work()) # blocks the loop
async def good(request):
return web.Response(text=await asyncio.to_thread(blocking_work))
async def main():
app = web.Application()
app.add_routes([web.get("/bad", bad), web.get("/good", good)])
async with TestClient(TestServer(app)) as c:
for path in ("/bad", "/good"):
t = time.perf_counter()
await asyncio.gather(*(c.get(path) for _ in range(5)))
print(path, round(time.perf_counter() - t, 1))
# /bad 1.0 (five requests run one after another)
# /good 0.2 (threads overlap)
asyncio.run(main())Remember
- C extensions (llhttp parser, multidict, yarl) make aiohttp fast
- One loop per process: any blocking call stalls all connections
- Use to_thread or process pools, asyncio debug mode and py-spy to find stalls
Watch out for
- Using a synchronous database driver inside handlers.