caduh

Async/Await Explained — a simplified mental model for asynchronous programming

4 min read

Understand the event loop, tasks vs. microtasks, promises, and async/await; how to write concurrent code safely; and the common pitfalls (await-in-loops, timeouts, cancellation).

TL;DR

  • Async functions return immediately with a Promise (JS) or Task (Python asyncio). await pauses that function only and schedules the rest to run when the awaited operation completes.
  • The event loop runs work in turns. I/O waits don’t block the loop; CPU work does — use workers/threads/processes for heavy CPU tasks.
  • For concurrency, start work first and then await together (Promise.all, asyncio.gather). Avoid serial await inside tight loops.
  • Always add timeouts/cancellation and handle errors. In JS: AbortController, Promise.race. In Python: asyncio.timeout, Task.cancel().
  • Prefer async iterators/streams and backpressure-friendly patterns for large data.

The mental model (60 seconds)

Event loop steps through a queue of tasks. When an async operation (network, file, timer) completes, a callback is queued. In modern code we write that callback as the continuation after await:

call stack        microtasks (promises)       tasks (timers, I/O)
----------        ----------------------      ---------------------
async fn ─ await ──► yields to loop  ──►      later, continuation runs
  • In JavaScript, async function → returns a Promise immediately. await p desugars to “pause this function; when p settles, resume with its value/error (microtask queue).”
  • In Python asyncio, async def returns a coroutine; the loop schedules it as a Task. await yields control to the loop so other tasks can run.

Async ≠ parallel: You get concurrency on one thread when operations frequently wait on I/O. For true CPU parallelism, use workers/threads/processes.


Sync vs Async vs Concurrent vs Parallel

  • Synchronous: do A, then B, then C — each must finish before the next starts.
  • Asynchronous: start A; while A is waiting, you can do B.
  • Concurrent: multiple things in progress at the same time (interleaved).
  • Parallel: multiple things executing simultaneously on different cores.

JavaScript patterns

Sequential (slow)

async function getTwo() {
  const a = await fetch("/api/a").then(r => r.json());
  const b = await fetch("/api/b").then(r => r.json());
  return [a, b]; // total time ~ a + b
}

Concurrent (better)

async function getTwo() {
  const pa = fetch("/api/a").then(r => r.json());
  const pb = fetch("/api/b").then(r => r.json());
  return Promise.all([pa, pb]); // time ~ max(a, b)
}

Avoid await in a plain for loop

// ❌ does 100 requests one-by-one
for (const id of ids) {
  const data = await fetch(`/api/items/${id}`);
}

Use map + Promise.all with optional concurrency limits:

const tasks = ids.map(id => () => fetch(`/api/items/${id}`).then(r => r.json()));

// simple: fire all at once (careful with 1000s)
const results = await Promise.all(tasks.map(fn => fn()));

// capped concurrency (tiny helper)
async function pool(fns, n){
  const ret = []; const p = new Set();
  for (const fn of fns){
    const pr = fn().then(v => { ret.push(v); p.delete(pr); });
    p.add(pr); if (p.size >= n) await Promise.race(p);
  }
  await Promise.all(p); return ret;
}
const resultsCapped = await pool(tasks, 5);

Timeouts & cancellation

// AbortController
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 5000);
try {
  const res = await fetch("/api/slow", { signal: ac.signal });
  const data = await res.json();
} finally {
  clearTimeout(timer);
}

Error handling

try {
  const [a, b] = await Promise.all([getA(), getB()]);
} catch (err) {
  // any failure in the group lands here
}

// capture all results (no short-circuit)
const results = await Promise.allSettled([getA(), getB()]);

Microtasks vs tasks (render timing)

console.log("A");
queueMicrotask(() => console.log("microtask"));
setTimeout(() => console.log("timeout 0"), 0);
console.log("B");
// order: A, B, microtask, timeout 0

Python asyncio patterns

Start first, await together

import asyncio, aiohttp

async def fetch(session, url):
    async with session.get(url) as r:
        return await r.text()

async def main(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [asyncio.create_task(fetch(session, u)) for u in urls]
        return await asyncio.gather(*tasks)  # waits for all

asyncio.run(main(["/a", "/b", "/c"]))

Timeouts & cancellation

import asyncio

async def work():
    await asyncio.sleep(10)

async def main():
    try:
        async with asyncio.timeout(5):
            await work()
    except TimeoutError:
        print("timed out")

asyncio.run(main())

Cancel outstanding tasks:

task = asyncio.create_task(work())
task.cancel()
try:
    await task
except asyncio.CancelledError:
    pass

Mixing CPU work (don’t block the loop)

import asyncio

def cpu_heavy(n):  # pure CPU
    s = 0
    for i in range(n): s += i*i
    return s

# run in a thread to keep event loop responsive
res = await asyncio.to_thread(cpu_heavy, 10_000_000)

Streams & backpressure

  • JS: ReadableStream / Node streams support async iteration:
for await (const chunk of stream) {
  // process as it arrives; apply backpressure naturally
}
  • Python: many libs expose async iterables (async for); process incrementally to avoid OOM.

Common pitfalls & fixes

| Problem | Why it hurts | Fix | |---|---|---| | await inside tight for loops | Serializes work | Start tasks first; Promise.all / asyncio.gather | | Blocking CPU on the main loop | UI/loop freezes, timeouts | Offload to workers/threads/processes | | No timeout/cancellation | Hangs forever | Use AbortController / asyncio.timeout and cancel | | Swallowing errors in Promise.all | Hard to debug | Use allSettled or handle per‑task | | Too many concurrent requests | rate limits, memory spikes | Use a pool / limit concurrency | | Forgetting return in async | Returns undefined wrapped | Always return a value/promise |


Quick checklist

  • [ ] Start work, then await together for concurrency.
  • [ ] Add timeouts and support cancellation.
  • [ ] Keep the loop non‑blocking; offload CPU.
  • [ ] Prefer async iterators/streams for large data.
  • [ ] Handle errors explicitly (group vs per‑task).
  • [ ] Limit concurrency when hitting external services.

One‑minute adoption plan

  1. Identify I/O hotspots (HTTP, DB, filesystem).
  2. Refactor to async/await; group awaits with Promise.all/gather.
  3. Add timeouts + cancellation paths.
  4. Add a concurrency limiter where needed.
  5. Watch p95 latency, event loop lag, and error rates; move CPU work off the loop.