๐Ÿง  Async 101

What async actually is, why it exists, and how the machinery works โ€” before you touch a single pattern.

The Problem: Waiting Is Wasted Time

Most real programs spend their lives waiting โ€” for a network reply, a database query, a file read, an API call. The CPU isn't busy during that wait. It's just... standing there.

Scenario: Fetch 3 URLs, each takes 1 second (the server is slow, not your code).

Synchronous: fetch #1 โ†’ wait 1s โ†’ fetch #2 โ†’ wait 1s โ†’ fetch #3 โ†’ wait 1s = 3 seconds.

Question: Why wait for #1 to finish before even starting #2? During each wait, the CPU is idle.

The Naive Fix People Reach For (Threads)

You could spin up 3 OS threads. It works, but threads are heavy: each costs memory, the OS schedules them preemptively, and shared state invites race conditions and locks. Async gets the same overlap for I/O work โ€” on one thread, cooperatively, without locks.

The Mental Model: One Cook, Many Dishes

A single cook can start the rice boiling, and while it boils chop vegetables, and while those roast plate a salad. One person, never idle, many dishes progressing at once. That's async: one worker that switches tasks whenever the current one hits a wait.

Sync vs Async on a Timeline

gantt title Fetching 3 slow URLs dateFormat X axisFormat %s section Synchronous URL 1 (wait) :0, 1 URL 2 (wait) :1, 2 URL 3 (wait) :2, 3 section Async URL 1 (wait) :0, 1 URL 2 (wait) :0, 1 URL 3 (wait) :0, 1
Key Insight: Async doesn't make any single request faster. It stops you from wasting the wait โ€” the three 1-second waits overlap instead of stacking up. 3 seconds becomes ~1 second.

Concurrency โ‰  Parallelism

This trips everyone up. They are not the same thing.

Rule of thumb: asyncio runs on a single thread. Only one line of your Python code runs at any given moment. It just switches tasks at every await. That's concurrency, not parallelism.

How It Works: The Event Loop

At the heart of asyncio is the event loop โ€” a scheduler that runs your coroutines. Each coroutine runs until it hits an await on something that isn't ready yet (a network reply). At that point it yields control back to the loop, which picks another ready task to run. When the awaited thing completes, the loop resumes the paused coroutine where it left off.

flowchart TB LOOP{{"Event Loop"}} LOOP -->|run| A["Task A runs"] A -->|"hits await (I/O not ready)"| LOOP LOOP -->|run| B["Task B runs"] B -->|"hits await (I/O not ready)"| LOOP LOOP -->|"A's I/O ready โ†’ resume"| A2["Task A finishes"] A2 --> LOOP LOOP --> DONE["โœ… all tasks complete"]
The trade: control is handed over cooperatively, only at an await. Nothing interrupts you mid-function. That's why there are no locks โ€” but also why one greedy task that never awaits will freeze the whole loop.

The Building Blocks: async and await

1. A coroutine is a function you can pause

async def defines a coroutine function. Calling it does not run it โ€” it hands you a coroutine object. Nothing happens until it's awaited or scheduled on the loop.

async def fetch(url):
    ...

coro = fetch("https://example.com")   # โ† nothing ran yet!
print(coro)   # <coroutine object fetch at 0x...>

2. await means "pause me until this is done"

await can only appear inside an async def. It runs an awaitable and, if it isn't ready, suspends the current coroutine so the loop can do other work.

import asyncio

async def main():
    print("start")
    await asyncio.sleep(1)   # โ† yields to the loop for 1s, doesn't block the CPU
    print("done, 1s later")

asyncio.run(main())   # โ† starts the event loop and runs main() to completion
The #1 mistake: calling a coroutine without awaiting it. fetch(url) on its own runs nothing and Python warns "coroutine was never awaited". You need await fetch(url).

Making Things Actually Overlap: Tasks & gather

Here's the subtlety: awaiting coroutines one after another is still sequential. To get overlap you must schedule them on the loop together, then await them.

import asyncio

async def fetch(name, seconds):
    print(f"{name} started")
    await asyncio.sleep(seconds)   # simulate a slow network call
    print(f"{name} done")
    return name

async def main():
    # โŒ Sequential: 3s total โ€” each await finishes before the next starts
    await fetch("A", 1)
    await fetch("B", 1)
    await fetch("C", 1)

    # โœ… Concurrent: ~1s total โ€” all three run while each other waits
    results = await asyncio.gather(
        fetch("A", 1),
        fetch("B", 1),
        fetch("C", 1),
    )
    print(results)   # ['A', 'B', 'C']

asyncio.run(main())
The three ways to run coroutines:
  • await coro() โ€” run one, wait for it (sequential).
  • asyncio.gather(a, b, c) โ€” run many concurrently, get all results back.
  • asyncio.create_task(coro()) โ€” schedule now, await later (fire-and-track).

When Should You Use Async?

Async is a scalpel, not a hammer. It only helps a specific kind of work.

Workload Example Use async?
I/O-bound (waiting on the outside world) HTTP requests, DB queries, reading files, sockets โœ… Yes โ€” this is exactly what it's for
CPU-bound (crunching numbers) Image resizing, math, parsing huge data, ML โŒ No โ€” use processes (it will just block the loop)
A single, simple script One request, then exit ๐Ÿคท No need โ€” sync is simpler
Why CPU-bound work breaks async: a heavy calculation never hits an await, so it never yields โ€” the single loop is stuck, and every other task starves. For that, offload to a process (see Pattern 4).

Common Gotchas

Things that bite everyone at first:
  • Forgetting await โ€” the coroutine silently never runs.
  • Calling blocking code (time.sleep, requests.get, heavy CPU) inside async โ€” it freezes the entire loop. Use asyncio.sleep and async libraries (httpx, aiohttp).
  • Awaiting in a loop when you meant to overlap โ€” that's sequential again. Reach for gather or tasks.
  • Expecting speedups on CPU work โ€” async gives concurrency, not parallelism. No extra cores.
  • Mixing sync and async worlds โ€” you can't await from normal code; you enter async through asyncio.run().

The One-Paragraph Recap

Async lets a single thread juggle many I/O-bound tasks by switching between them whenever one is waiting. You write async def coroutines, pause them with await, and an event loop schedules whichever task is ready. Run things together with gather or create_task to make the waits overlap. It's not faster CPU โ€” it's never sitting idle.

That's the whole idea. Every pattern in this collection is just a disciplined way of applying it.