26.3 Asynchronous Programming
Table of Contents
Why Asynchronous Programming Matters for Backend Performance
When your backend is slow, it is often because it is waiting on something: a database, an external API, disk I/O, or network. During this wait time, the CPU is mostly idle. Asynchronous programming is a way to use that idle time to handle other work without adding more machines.
Asynchronous techniques are especially useful for:
- Handling many concurrent HTTP requests
- Talking to slow external services
- Streaming data
- Long-running I/O operations like file uploads and downloads
You already learned general ideas about backend performance and CPU-bound vs I/O-bound work in the surrounding chapters, so this chapter focuses on what is specific to async programming and how it helps with performance and scalability.
Key idea: Asynchronous programming is mainly a tool to improve concurrency for I/O-bound workloads, not to make pure CPU calculations faster.
Concurrency vs Parallelism
Before we talk about async code, we must distinguish two related concepts.
| Concept | Simple meaning | Typical use |
|---|---|---|
| Concurrency | Doing many things in progress at the same time | Handling many I/O-bound requests |
| Parallelism | Doing computations literally at the same time | Heavy CPU tasks, numeric computation |
In a single-threaded asynchronous server:
- Only one piece of Python code runs at a time.
- That code voluntarily pauses while waiting on I/O.
- The event loop then runs other tasks that are ready.
So async code gives you high concurrency with a single thread. Parallelism, for heavy CPU-bound work, usually requires multiple processes or threads, which is covered in other chapters.
Synchronous vs Asynchronous I/O
Imagine a handler that calls another HTTP service:
Synchronous style
def get_user_profile(user_id: int):
response = requests.get(f"https://api.example.com/users/{user_id}")
data = response.json()
return dataIn a typical synchronous web server:
- A thread handles the request.
- It calls
requests.get(...). - The thread is blocked waiting for the remote server.
- While it waits, that thread cannot do anything else.
If network latency is 300 ms and you have 50 concurrent requests, you may need 50 threads steadily waiting on remote services.
Asynchronous style
With async I/O:
import httpx
import asyncio
async def get_user_profile(user_id: int):
async with httpx.AsyncClient() as client:
response = await client.get(f"https://api.example.com/users/{user_id}")
data = response.json()
return dataWhat changes:
- The function is marked
async def, so it returns a coroutine. - The I/O call
client.get(...)is awaitable, so weawaitit. - When we
await, we yield control to the event loop. - The event loop can now run other requests while the HTTP call waits.
The CPU is free to work on other incoming requests instead of sitting idle.
Rule: In async code, any operation that can block for a noticeable time must be awaited using an async-compatible library, otherwise you lose the benefits of async.
Event Loop and Tasks, Conceptually
Most modern async systems use an event loop.
You can think of the event loop as:
- A scheduler that has a queue of ready tasks.
- It picks a task and runs it until the task
awaits something. - When a task
awaits an I/O operation, it is paused. - When the I/O is ready, the task is put back on the ready queue.
Some terminology:
| Term | Meaning |
|---|---|
| Event loop | Core engine that runs async tasks and dispatches I/O events |
| Coroutine | An async function that can be paused by await and later resumed |
| Task | A scheduled coroutine managed by the event loop |
| Awaitable | Something you can await on, usually returned by async functions |
You do not need to build your own event loop. Frameworks like FastAPI and async libraries do it for you. But you must write code that is cooperative:
- Your async code should frequently
awaitrather than do huge CPU work without breaks. - Every
awaitgives the event loop a chance to serve other tasks.
I/O-Bound Work and Async Benefits
In this course you already distinguish CPU-bound and I/O-bound work. Here is how async fits into that.
When async works very well
Async is great when you have a lot of waiting involved:
- Calling external APIs
- Database access with async drivers
- Reading and writing files or cloud storage
- WebSockets and streaming responses
Example of an I/O-bound async endpoint:
import asyncio
import httpx
async def fetch_two_apis():
async with httpx.AsyncClient() as client:
# Start both requests concurrently
task1 = asyncio.create_task(client.get("https://api.service1.com/data"))
task2 = asyncio.create_task(client.get("https://api.service2.com/data"))
# Wait for both responses
resp1, resp2 = await asyncio.gather(task1, task2)
return resp1.json(), resp2.json()
Here, while we wait for service1, the event loop can be waiting for service2 at the same time. The total time is roughly:
$$
T_{\text{total}} \approx \max(T_1, T_2)
$$
Instead of:
$$
T_{\text{total}} \approx T_1 + T_2
$$
Performance insight: For multiple independent I/O operations, asynchronous concurrency can reduce total time from the sum of latencies to roughly the maximum latency.
When async does *not* help much
For CPU-bound tasks, async alone is not enough. For example:
async def slow_fibonacci(n: int) -> int:
if n <= 1:
return n
return await slow_fibonacci(n - 1) + await slow_fibonacci(n - 2)
This is an artificial recursive example. Even though the function is async, the CPU must still do the same amount of work. The event loop cannot speed this up, because you are not waiting on I/O, just burning CPU.
For heavy CPU-bound work, you typically:
- Move the work to a background worker process.
- Use multiprocessing or specialized computing services.
- Keep your main web server focused on I/O-bound tasks.
Async-Aware Libraries vs Blocking Libraries
Async programming is effective only if your whole stack is async-aware.
| Layer | Synchronous example | Asynchronous example |
|---|---|---|
| HTTP client | requests | httpx.AsyncClient, aiohttp |
| DB client | psycopg2 | asyncpg, databases, SQLAlchemy async |
| Redis client | redis-py (sync) | aioredis, redis.asyncio |
| Web framework | Flask (sync) | FastAPI, Starlette (async-first) |
If you mix blocking libraries in async endpoints, the event loop will be stuck while your code waits, and concurrency will drop.
Example of a bad async endpoint that uses a blocking HTTP client:
import requests
async def bad_handler():
# This blocks, even in async code
response = requests.get("https://api.example.com/data")
return response.json()Better version that uses an async client:
import httpx
async def good_handler():
async with httpx.AsyncClient() as client:
response = await client.get("https://api.example.com/data")
return response.json()Rule: In async endpoints, always use async versions of I/O libraries. A single blocking call can block the entire event loop.
Combining Async with Thread Pools and Process Pools
Even if your main application is async, you sometimes must call blocking code.
Common cases:
- A legacy library has no async version.
- A heavy CPU-bound function must run, but you want to keep the event loop responsive.
In Python, one typical pattern is to offload work to a thread or process pool.
Example, running blocking code in a thread so it does not block the event loop:
import asyncio
import time
def blocking_sleep(seconds: int):
time.sleep(seconds)
return f"Slept for {seconds} seconds"
async def handler():
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, blocking_sleep, 3)
return resultWhat happens here:
blocking_sleepis a normal blocking function.run_in_executorsends it to a thread pool.- The event loop can continue running other async tasks while the thread sleeps.
You do not need to manage threads directly. For most backend applications, using an async-first stack avoids this complexity, but it is useful to know this option exists when integrating with blocking code.
Measuring the Impact of Async
You can think of async programming as a way to multiply the useful work your server does for I/O-bound tasks, without increasing CPU speed.
Very simplified, if:
- Each request spends 90 % of its time waiting on I/O.
- 10 % of its time actually uses CPU.
Then:
- In a blocking model, one thread is tied to one request.
- In an async model, one event loop can keep many requests in progress, because while one waits, another uses the CPU.
You can see benefits in:
- Higher throughput: your server handles more requests per second.
- Lower latency under load: requests do not queue behind slow ones as badly.
- Lower resource usage: fewer threads and lower memory usage.
However, async does not magically remove bottlenecks like:
- A slow database query
- An overloaded external service
- Inefficient algorithms
Those need separate optimizations, which are covered in the database and performance chapters.
Practical Guidelines for Backend Developers
To use async effectively in backend systems:
1. Use async where concurrency matters
Typical places:
- HTTP endpoints in FastAPI
- Background workers that make many external calls
- WebSocket handlers for real-time communication
- Services that aggregate data from multiple external APIs
2. Keep heavy CPU work outside async hot paths
For example:
- Do not compute large reports synchronously in an HTTP request if it takes seconds.
- Instead, send a job to a background worker and return quickly.
Async is about handling many requests efficiently, not about making each request's CPU work faster.
3. Choose your stack with async in mind
If you plan to use FastAPI or other async frameworks:
- Pick database drivers that support async queries.
- Use async HTTP clients.
- Use Redis clients that support async.
In this course, when you later build complete FastAPI applications, you will see how async endpoints, async DB clients, and async worker patterns combine to give very high concurrency.
4. Avoid unnecessary context switches
While await is good for I/O, it still has a small overhead. Do not split tiny CPU operations into many async calls.
For example, this is unnecessary:
async def add(a, b):
return a + b
async def handler():
# This await is pointless
result = await add(1, 2)
return resultUse async only when you actually perform I/O or need concurrency, such as network calls, file operations, or waiting for timers.
Example: Parallel Calls to Multiple Services
Imagine an endpoint that must call three external services and combine their data.
Synchronous version
import requests
def aggregate_data():
data1 = requests.get("https://service1/api/data").json()
data2 = requests.get("https://service2/api/data").json()
data3 = requests.get("https://service3/api/data").json()
return {"s1": data1, "s2": data2, "s3": data3}If each call takes about 200 ms, total time is around:
$$
T_{\text{sync}} \approx 200 + 200 + 200 = 600 \text{ ms}
$$
Async version
import asyncio
import httpx
async def aggregate_data_async():
async with httpx.AsyncClient() as client:
t1 = asyncio.create_task(client.get("https://service1/api/data"))
t2 = asyncio.create_task(client.get("https://service2/api/data"))
t3 = asyncio.create_task(client.get("https://service3/api/data"))
r1, r2, r3 = await asyncio.gather(t1, t2, t3)
return {
"s1": r1.json(),
"s2": r2.json(),
"s3": r3.json(),
}Now the calls overlap. Total time is roughly:
$$
T_{\text{async}} \approx \max(200, 200, 200) \approx 200 \text{ ms}
$$
On a busy server, the difference is even larger, because async lets many such aggregations run concurrently with one event loop.
Pattern: For independent external calls, start them together and await them with asyncio.gather to overlap their waiting time and reduce total latency.
Summary
In this chapter you learned how asynchronous programming fits into backend performance:
- Async is about concurrency, not raw CPU speed.
- It is most useful for I/O-bound tasks such as network and disk operations.
- The event loop runs many tasks that voluntarily yield control with
await. - You must use async-compatible libraries to get real benefits.
- Async helps you handle many concurrent requests with fewer resources and lower latencies, which is crucial for scalable backends.
Later, when you build FastAPI applications and integrate databases, Redis, and background workers, these async concepts will become very concrete and practical.
Views: 8
KAHIBARO