Asyncio isn’t “magic speed” — it’s a way to run lots of I/O without creating lots of threads. In this post you’ll learn asyncio by shipping three small, real programs: an async scraper, a resilient WebSocket client, and a tiny task queue with workers, retries, and graceful shutdown.
Quickstart
Want the fastest path to “I get it”? Do this in order. Each step is small, but together they teach you the core asyncio habits: await I/O, limit concurrency, handle timeouts/cancellation, and shut down cleanly.
1) Confirm asyncio is the right tool
Asyncio shines for I/O-bound work: HTTP calls, sockets, DB queries, queues, files (with async libs).
- Yes: many network calls, high concurrency, mostly waiting
- No: heavy CPU loops (use multiprocessing or offload to threads/processes)
- Mixed: keep the event loop free; isolate blocking pieces
2) Adopt the “async contract”
Inside async code, you can only “pause” at await. Anything else blocks everyone.
- Replace
time.sleep()withawait asyncio.sleep() - Use async libraries (HTTP, DB, websockets)
- Wrap blocking calls with
asyncio.to_thread()when needed
3) Learn two patterns: gather + workers
These are the 80/20 building blocks you’ll reuse everywhere.
- gather: run N independent I/O operations concurrently
- workers: a bounded queue + a fixed number of consumers
- always: timeouts, retries, and limits
4) Use this post like a checklist
Build one project now, then revisit the others when you need them.
- Start with the scraper to understand concurrency limits
- Build the WebSocket client to learn long-lived connections
- Finish with the task queue to practice backpressure + shutdown
If your program spends most of its time waiting on the network, asyncio can make it simpler and faster. If it spends most of its time crunching numbers, asyncio won’t save you.
Overview
Python’s asyncio is a single-threaded concurrency model built around an event loop. Instead of spawning a thread per request, you write code that cooperatively yields control whenever it hits an awaitable I/O operation. That lets one process handle thousands of sockets efficiently — as long as you avoid blocking calls.
What you’ll build (and what each project teaches)
| Project | Core skill | Real-world use |
|---|---|---|
| Async scraper | Bounded concurrency, retries, timeouts | APIs, crawling, batch ingestion |
| WebSocket client | Long-lived tasks, reconnect loops, cancellation | Live dashboards, trading feeds, chat, telemetry |
| Task queue | Backpressure, worker pools, graceful shutdown | Pipelines, ETL, async job runners, fan-out work |
The goal isn’t to memorize APIs. It’s to build the mental model: the event loop runs tasks, tasks pause at await, and your job is to keep the loop responsive while managing concurrency and failures.
Asyncio gives you concurrency: many in-flight operations in one thread. It does not magically give you parallel CPU. For CPU-heavy work, use processes, native extensions, or offload blocking work to threads.
Core concepts
Asyncio clicks when you stop thinking “my function runs from top to bottom” and start thinking “my function is a task that takes turns with other tasks”. Here are the pieces you’ll meet in every real async program.
Coroutines and await
An async function returns a coroutine object. Nothing inside it runs until you await it
(directly or indirectly via a task). When you await an awaitable (like an HTTP request), your coroutine yields control
back to the event loop so other tasks can run.
Event loop
The event loop is the scheduler. It watches sockets and timers, wakes up tasks whose I/O completed,
and runs them until they hit the next await. If you block the loop (CPU loop, blocking I/O, time.sleep),
you block everything.
Tasks: “run this coroutine in the background”
A Task wraps a coroutine and schedules it. You create tasks when you want multiple coroutines to progress concurrently.
In modern Python, prefer structured concurrency with asyncio.TaskGroup (3.11+) because it makes lifetimes explicit:
if a child task fails, the group cancels the others and bubbles up the error.
Timeouts, cancellation, and why they matter
Real networks fail. A robust asyncio program treats timeouts and cancellation as normal control flow: set deadlines, catch errors, and shut down without leaving dangling tasks.
What “good async” feels like
- Every external call has a timeout
- Concurrency is bounded (semaphores / worker pools)
- Cancellation is handled (no zombie tasks)
- Failures are logged with enough context to fix
When not to use asyncio
- CPU-bound processing dominates runtime
- Your dependencies are mostly blocking-only
- You need true parallelism more than I/O concurrency
- Your concurrency level is tiny (simple threads may be fine)
Mental model: “restaurant kitchen”
The event loop is the head chef. Tasks are cooks. await is “I’m waiting on the oven — do someone else’s work.”
Blocking calls are “I left the kitchen to buy groceries,” and everything stops.
Mixing async code with a blocking library (HTTP, DB, filesystem) without offloading. If you see “async” but your throughput doesn’t improve, it’s often because you’re still blocking the loop.
Step-by-step
We’ll build three “real enough” asyncio projects. Each one is small, but includes the production habits that matter: timeouts, limits, retries, and clean shutdown. You can copy these patterns into your own services.
Prep: environment and dependencies
- Python: 3.11+ recommended (TaskGroup + improved asyncio ergonomics)
- Install:
pip install aiohttp websockets - Tip: run these scripts from a virtualenv and keep dependencies explicit per project
Project 1 — Async scraper with bounded concurrency
Scraping is a perfect asyncio use case: the program spends most of its time waiting on HTTP responses. The trick is not “go faster” — it’s “go fast without being rude”: limit concurrency, use timeouts, and retry intelligently.
What we’ll implement
- One shared HTTP session (connection pooling)
- Semaphore to cap in-flight requests
- Per-request timeouts
- Retry with small exponential backoff
- Return results (status + body size) for quick validation
Common gotchas
- Unbounded
gather→ too many sockets - No timeout → hangs forever on one URL
- Creating a session per request → slow + wasteful
- Ignoring non-200 responses → silent data holes
import asyncio
from dataclasses import dataclass
from typing import Iterable, Optional
import aiohttp
@dataclass(frozen=True)
class FetchResult:
url: str
ok: bool
status: int
bytes_read: int
async def fetch_with_retry(
session: aiohttp.ClientSession,
sem: asyncio.Semaphore,
url: str,
*,
timeout_s: float = 10.0,
retries: int = 2,
backoff_base_s: float = 0.4,
) -> FetchResult:
# Keep the event loop responsive: acquire semaphore, then await I/O.
async with sem:
for attempt in range(retries + 1):
try:
timeout = aiohttp.ClientTimeout(total=timeout_s)
async with session.get(url, timeout=timeout) as resp:
body = await resp.read()
return FetchResult(
url=url,
ok=(200 <= resp.status < 300),
status=resp.status,
bytes_read=len(body),
)
except (aiohttp.ClientError, asyncio.TimeoutError):
if attempt >= retries:
return FetchResult(url=url, ok=False, status=0, bytes_read=0)
await asyncio.sleep(backoff_base_s * (2 ** attempt))
async def scrape(urls: Iterable[str], *, concurrency: int = 20) -> list[FetchResult]:
sem = asyncio.Semaphore(concurrency)
headers = {"User-Agent": "UniLabAsyncScraper/1.0 (+https://bloglab-65579.firebaseapp.com/)"}
connector = aiohttp.TCPConnector(limit=concurrency, ttl_dns_cache=300)
async with aiohttp.ClientSession(headers=headers, connector=connector) as session:
tasks = [asyncio.create_task(fetch_with_retry(session, sem, url)) for url in urls]
# return_exceptions=False: fail loudly during development; add logging if needed.
return await asyncio.gather(*tasks)
def main() -> None:
urls = [
"https://example.com",
"https://httpbin.org/delay/1",
"https://httpbin.org/status/404",
]
results = asyncio.run(scrape(urls, concurrency=10))
for r in results:
print(f"{r.url} ok={r.ok} status={r.status} bytes={r.bytes_read}")
if __name__ == "__main__":
main()
Add a simple parser (BeautifulSoup / lxml), save structured results, and log failures with the URL and exception type. If you’re hitting a third-party site, respect robots.txt and rate limits.
Project 2 — WebSocket client with reconnect + graceful shutdown
WebSockets are the opposite of scraping: the connection stays open and messages stream in. That means you need long-lived tasks, heartbeats, and a reconnect loop that doesn’t spin out of control when the network is flaky.
What we’ll implement
- Connect + read messages in a loop
- Periodic ping/heartbeat so idle links stay alive
- Reconnect with exponential backoff
- Clean cancellation (Ctrl+C / shutdown)
Why this pattern works
The event loop is great at managing one “forever connection” plus background timers. But you must treat disconnects as normal. Backoff prevents “reconnect storms” that can DOS your own service.
import asyncio
import contextlib
from typing import Optional
import websockets
async def heartbeat(ws: websockets.WebSocketClientProtocol, interval_s: float) -> None:
while True:
await asyncio.sleep(interval_s)
# ping() returns a Future you can await for pong if you want strictness
await ws.ping()
async def consume(ws: websockets.WebSocketClientProtocol) -> None:
async for msg in ws:
# Keep message handling fast; if it becomes heavy, push to a queue/to_thread().
print("recv:", msg)
async def run_client(url: str, *, stop: asyncio.Event) -> None:
backoff_s = 0.5
max_backoff_s = 10.0
while not stop.is_set():
try:
async with websockets.connect(url, ping_interval=None) as ws:
backoff_s = 0.5 # reset on successful connect
hb = asyncio.create_task(heartbeat(ws, interval_s=15.0))
consumer = asyncio.create_task(consume(ws))
# Wait until stop is set OR a task fails/completes.
done, pending = await asyncio.wait(
{hb, consumer, asyncio.create_task(stop.wait())},
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
with contextlib.suppress(asyncio.CancelledError):
await asyncio.gather(*pending)
except (OSError, websockets.WebSocketException):
await asyncio.sleep(backoff_s)
backoff_s = min(max_backoff_s, backoff_s * 2)
async def main() -> None:
stop = asyncio.Event()
url = "wss://echo.websocket.events" # public echo server (demo)
client = asyncio.create_task(run_client(url, stop=stop))
try:
# Send a few demo messages by opening a short-lived connection (optional).
async with websockets.connect(url) as ws:
for i in range(3):
await ws.send(f"hello {i}")
await asyncio.sleep(0.2)
except Exception:
pass
# Let the client run briefly.
await asyncio.sleep(3.0)
stop.set()
await client
if __name__ == "__main__":
asyncio.run(main())
If message handling becomes CPU-heavy or blocking, push work to an asyncio.Queue and let a worker pool process it,
or offload to asyncio.to_thread(). The consumer loop must stay responsive to avoid backpressure and disconnects.
Project 3 — Tiny task queue with worker pool + backpressure
A task queue is a great way to learn “structured async” without any external infrastructure.
You’ll build a bounded asyncio.Queue, spawn a fixed number of workers, and implement shutdown correctly.
This pattern shows up everywhere: ETL, background jobs, pipelines, and fan-out processing.
What we’ll implement
- Bounded queue (backpressure)
- N workers consuming tasks
- Retries for transient failures
- Graceful shutdown with sentinels
Backpressure in one sentence
If producers can enqueue infinitely, memory becomes your “queue”. A bounded queue forces the system to slow down safely.
import asyncio
import random
from dataclasses import dataclass
@dataclass(frozen=True)
class Job:
id: int
payload: str
attempts_left: int = 2
SENTINEL: Job | None = None
async def handle(job: Job) -> None:
# Simulate I/O work (replace with HTTP/DB/etc).
await asyncio.sleep(0.05)
# Simulate occasional transient failures.
if random.random() < 0.15:
raise RuntimeError("transient failure")
async def worker(name: str, q: asyncio.Queue[Job | None]) -> None:
while True:
job = await q.get()
try:
if job is SENTINEL:
return
try:
await handle(job)
print(f"[{name}] done job={job.id}")
except Exception:
if job.attempts_left > 0:
# Re-queue with one fewer attempt (simple retry).
await q.put(Job(job.id, job.payload, job.attempts_left - 1))
else:
print(f"[{name}] failed job={job.id} (no attempts left)")
finally:
q.task_done()
async def main() -> None:
q: asyncio.Queue[Job | None] = asyncio.Queue(maxsize=50) # bounded => backpressure
workers = [asyncio.create_task(worker(f"w{i}", q)) for i in range(5)]
# Producer: enqueue work. If q is full, await will pause the producer (good).
for i in range(200):
await q.put(Job(i, payload=f"item-{i}"))
# Wait until all jobs (including retries) are processed.
await q.join()
# Shut down workers with sentinels.
for _ in workers:
await q.put(SENTINEL)
await asyncio.gather(*workers)
if __name__ == "__main__":
asyncio.run(main())
Upgrade ideas (when you want more realism)
- Add per-job timeouts (e.g.,
asyncio.wait_for) - Add jittered backoff for retries to avoid thundering herds
- Attach metadata (created_at, trace_id) and log failures with context
- Use
asyncio.TaskGroupfor structured worker startup/shutdown
Common mistakes
Most asyncio pain comes from a few repeat offenders. If your async code feels “slow” or “unreliable,” scan this list first.
Mistake 1 — Blocking the event loop
One blocking call can stall every task.
- Symptoms: throughput flatlines, timeouts pile up, “async” feels like sync
- Fix: use async libs; replace
time.sleep; offload withasyncio.to_thread
Mistake 2 — Unbounded concurrency
Creating 50k tasks is not “free.” It can melt sockets, memory, and the remote service.
- Symptoms: “Too many open files,” random connection resets
- Fix: semaphore, connector limits, worker pools, bounded queues
Mistake 3 — Fire-and-forget tasks
If you create a task and never await/join it, errors can be lost and shutdown gets messy.
- Symptoms: “Task exception was never retrieved” warnings, missing work
- Fix: track tasks, use TaskGroup, or await gather on shutdown
Mistake 4 — No timeouts or cancellation handling
Networks hang. Services stall. Your code must have an exit plan.
- Symptoms: program stuck forever on one request
- Fix: timeouts on external calls; treat cancellation as normal control flow
Mistake 5 — Doing CPU-heavy work inside async tasks
CPU loops don’t yield. The event loop can’t schedule other tasks.
- Fix: batch CPU work to processes, or offload to thread/process pools
- Fix: separate “I/O stage” (async) from “compute stage” (parallel)
Mistake 6 — Confusing “works on my machine” with resilient
A demo script isn’t production behavior.
- Fix: add retries/backoff, log errors with context, handle partial failures
- Fix: test with flaky network conditions (timeouts, disconnects)
When things freeze, look for: synchronous HTTP clients, blocking DB drivers, heavy parsing, file I/O, or time.sleep.
In asyncio, performance problems are often “one blocking line” problems.
FAQ
When should I use asyncio instead of threads?
Use asyncio when you have many concurrent I/O operations (HTTP calls, sockets, DB queries) and you want predictable resource usage. Threads can work too, but they cost more memory and scheduling overhead at high concurrency. For small concurrency, threads are fine.
Will asyncio make my program faster?
It will make I/O-heavy programs more throughput-efficient by keeping one process busy while requests are in flight. It won’t speed up CPU-bound work. If your bottleneck is computation, use multiprocessing or optimized native code.
What’s the difference between a coroutine and a task?
A coroutine is the thing returned by calling an async def function. A task is a scheduled coroutine managed by the event loop.
You typically create tasks when you want work to progress concurrently with other coroutines.
How do I prevent “too many open connections” errors?
Bound concurrency. Use a semaphore or a worker pool. For HTTP, reuse a single session and limit the connector concurrency. Avoid creating one session/connector per request.
How do I handle timeouts and retries correctly?
Put timeouts on every external call, then retry only on transient errors (timeouts, connection resets, 5xx). Add backoff (and ideally jitter) so failures don’t cause reconnect storms. Keep retries bounded and observable via logs/metrics.
What is structured concurrency and why should I care?
Structured concurrency means task lifetimes are scoped: child tasks belong to a parent block.
In Python 3.11+, asyncio.TaskGroup helps you avoid orphan tasks and makes failures/cancellation predictable.
It’s one of the simplest ways to make async code feel “safe.”
Cheatsheet
A scan-fast checklist for writing asyncio code that behaves well under load.
Asyncio essentials
- Await I/O: async HTTP/DB/websocket libs
- Don’t block: no
time.sleep, no heavy CPU loops - Bound concurrency: semaphore / worker pool / bounded queue
- Timeout everything: external calls must have deadlines
- Handle cancellation: shut down cleanly, cancel pending tasks
Project patterns
- Scraping/APIs: session + semaphore + retries + backoff
- Streaming: consumer loop + heartbeat + reconnect backoff
- Pipelines: bounded queue + N workers +
join+ sentinels - Compute inside async: offload via
asyncio.to_threador processes
“If this happens, do that”
| Symptom | Likely cause | Fix |
|---|---|---|
| Program freezes under load | Blocking call in event loop | Switch to async lib or to_thread |
| “Too many open files” | Unbounded concurrency | Add semaphore/connector limits |
| Random timeouts | Overloading remote service / no backoff | Lower concurrency, add retries + backoff |
| Missing work on shutdown | Fire-and-forget tasks | Track/await tasks; use TaskGroup |
Asyncio is a concurrency tool, not a performance cheat code. Make waiting efficient, and keep the loop unblocked.
Wrap-up
If asyncio felt abstract before, you now have three concrete patterns you can reuse: bounded concurrent HTTP, resilient long-lived connections, and queue + workers with backpressure and clean shutdown. Those three cover a surprising amount of real-world Python concurrency.
Next actions (pick one)
- Turn the scraper into a small ingestion tool (CSV/JSON output)
- Point the WebSocket client at a real feed and add message parsing + buffering
- Use the task queue to build a mini pipeline (fetch → transform → write)
- Audit one existing “async” project for hidden blocking calls
When you’re ready to go deeper
- Learn structured concurrency (
asyncio.TaskGroup) - Instrument: latency, queue size, retry counts
- Design for failure: timeouts, fallbacks, circuit breakers
- Separate I/O from CPU stages (async + parallel compute)
Keep this page bookmarked: Quickstart for getting started, Cheatsheet for day-to-day patterns, and the projects when you need a template.
Quiz
Quick self-check (demo). This quiz is auto-generated for programming / concurrency / asyncio.