Asyncio is not a silver bullet: When simple threading beats complexity

4 min read

A cluttered desk with a laptop screen showing Python code and a cup of cold tea.

Why you should stop overusing Python async for simple I/O tasks

If you are wrapping every single HTTP request or file system operation in asyncio just because you saw it in a blog post, you are likely introducing unnecessary technical debt. Asyncio is a tool for high-concurrency, long-lived connections, not a drop-in performance booster for routine I/O. When you build simple microservices—like those often found in GOV.UK internal tooling—you often gain nothing but a stack trace that is impossible to debug and a dependency graph that makes your head spin.

The Python GIL (Global Interpreter Lock) remains the elephant in the room, but people forget that asyncio does not bypass it; it merely provides cooperative multitasking. If your task is simply hitting an API, parsing a small JSON response, and saving it to a database, the overhead of the event loop is actually slower than standard, synchronous blocking calls managed by a ThreadPoolExecutor.

The hidden overhead of the event loop

Every time you define an async def function, you are creating a coroutine object. You then need an event loop to schedule and run that coroutine. In a simple web crawler or a data ingestion script for NHS Digital’s open data, this adds layer upon layer of indirection. You are essentially building a state machine where you only needed a top-to-bottom procedural script.

Why your benchmarks lie

Most developers test async performance by firing 10,000 requests at a mock server. In reality, your code is likely hitting one or two internal endpoints with varying latency. In these real-world scenarios, the overhead of context switching between coroutines—even if it is just a few microseconds—outweighs the performance cost of a thread context switch. Threads in Python are managed at the OS level and, for I/O bound tasks, the OS is remarkably efficient at parking a thread while waiting for a socket response.

import requests
from concurrent.futures import ThreadPoolExecutor

def fetch_data(url):
    return requests.get(url).json()

urls = ['https://api.gov.uk/data-1', 'https://api.gov.uk/data-2']

with ThreadPoolExecutor(max_workers=5) as executor:
    results = list(executor.map(fetch_data, urls))

The code above is readable, debuggable, and performs admirably for low-to-medium throughput. It doesn't require aiohttp, httpx, or the cognitive load of ensuring every dependency in your stack is also async-compatible.

The trap of viral async dependencies

Once you go async, everything underneath you must be async. If you use a legacy database driver that doesn't support non-blocking I/O, you end up blocking the entire event loop, effectively halting your application. This is the 'Async Poison' problem. If you’ve ever tried to integrate an old C-extension or a synchronous library into an asyncio app, you know the frustration of having to wrap every call in loop.run_in_executor, which eventually defeats the purpose of writing async code in the first place.

Think about a service processing HMRC tax notifications. If you use standard synchronous libraries, you can leverage mature tools like requests or boto3 without jumping through hoops. When you force these into an async paradigm, you spend more time managing compatibility shims than writing business logic. The maintainability cost of an async codebase is significantly higher for junior developers or new team members jumping into a project.

When async is actually the right tool

I am not saying asyncio is useless. It excels when you have thousands of concurrent idle connections—like a WebSockets server managing real-time stock ticks or a heavy-duty notification pusher for a platform like Monzo. If you have 5,000 open connections and you need to push updates to them based on external triggers, threads will consume too much memory and OS resources. That is the domain where asyncio and uvloop shine.

However, for the average internal service or data pipeline, your bottleneck is almost never the lack of async. It is usually database query design, lack of indexing, or poor HTTP client timeout configurations. Fix your SQL queries before you start refactoring your entire codebase to use await for every single line of code.

FAQ

Does threading in Python offer true concurrency?

For I/O bound tasks, yes. While the GIL prevents multiple threads from executing Python bytecode simultaneously, it releases the lock during I/O operations (like network sockets or disk reads), allowing other threads to run. This is perfectly fine for 90% of web service requirements.

Is asyncio always slower than threading?

No, but it is more complex. Asyncio has lower memory overhead per connection compared to threads, which is why it wins at extreme scale. For simple tasks, the performance difference is negligible, but the complexity cost of async is immediate and constant.

Can I mix sync and async code safely?

You can, but it is dangerous. Running synchronous code inside an event loop blocks the entire loop, which stops all other coroutines from running. If you must do it, always offload the synchronous code to an executor using loop.run_in_executor(), but ideally, avoid the mix to keep your architecture clean.