Async/await showed up in JavaScript in 2017 and Python in 3.5, and by 2026 it is the default way to write non-blocking code in both languages — and increasingly in Rust, Swift, and Kotlin too. Yet the same handful of bugs appear in every codebase: serial awaits in loops, forgotten error handling, and the belief that async magically parallelises CPU work. This guide fixes all three.
What changed in 2026
- Node.js 22 made unhandled promise rejections fatal by default. You can no longer silently drop errors.
- Python 3.13 reduced asyncio overhead and made
asyncio.TaskGroup the idiomatic way to run concurrent tasks (replacing bare gather calls).
- TypeScript 5.5 added stricter async return-type inference, catching places where
async was added without returning a meaningful promise.
- Top-level await is now stable in Node.js ES modules and Deno, so the async wrapping dance at module level is gone.
How async/await actually works
Under the hood, async functions return a promise (JS) or a coroutine (Python). The await keyword yields control back to the event loop and resumes when the awaited value resolves. No new thread is created.
// JavaScript — Node.js 22
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
The runtime is still single-threaded. The concurrency comes from the event loop scheduling I/O callbacks while your code waits. CPU-bound work blocks the loop.
The serial-await trap
The single most common mistake: awaiting inside a for loop.
// BAD — each fetch waits for the previous one (serial)
for (const id of userIds) {
const user = await fetchUser(id);
users.push(user);
}
// GOOD — all fetches in flight simultaneously
const users = await Promise.all(userIds.map(fetchUser));
In Python the equivalent is asyncio.gather or (in 3.11+) asyncio.TaskGroup:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch_user(uid)) for uid in user_ids]
# tasks[i].result() after the block
Comparison table
| Pattern |
JS syntax |
Python syntax |
Notes |
| Single await |
await fn() |
await fn() |
Identical |
| Run N tasks concurrently |
Promise.all(arr) |
asyncio.gather(*coros) |
Fails fast on first error |
| Run N, ignore failures |
Promise.allSettled(arr) |
asyncio.gather(*coros, return_exceptions=True) |
|
| Timeout a call |
AbortSignal.timeout(ms) |
asyncio.wait_for(coro, timeout) |
|
| Background task |
— |
asyncio.create_task(coro) |
Fire and forget (but handle errors) |
Error handling
Every awaited call can throw. Wrap critical paths in try/catch:
async function loadDashboard(userId) {
try {
const [user, stats] = await Promise.all([
fetchUser(userId),
fetchStats(userId),
]);
return { user, stats };
} catch (err) {
logger.error({ userId, err }, "dashboard load failed");
throw err; // re-throw so the caller knows
}
}
In Python, asyncio.TaskGroup cancels all siblings when one raises, which is usually the right behaviour for "all or nothing" batches.
How to pick
- Single dependent call — plain
await is fine.
- Multiple independent calls — use
Promise.all / gather; never await in a loop.
- CPU-heavy work — use worker threads (Node
worker_threads) or ProcessPoolExecutor (Python); async does not help.
- Need partial results on failure — use
Promise.allSettled or return_exceptions=True.
- Streaming data — use async generators (
for await...of / async for).
Common mistakes
Forgetting await. The function returns a promise object instead of the resolved value. TypeScript's strict mode catches this; enable it.
Making everything async. Sync helper functions do not need async just because their caller is async. It adds overhead and muddies the callstack.
Mixing promise chains and await. Pick one style per codebase. Promise chains are fine for simple transforms; async/await wins for control flow.
Swallowing errors. An empty catch {} block is worse than no catch — you lose the error silently. Log or rethrow.
What to skip
- Callback-based wrappers when a native Promise API exists.
fs.promises, fetch, and the standard library all have async-native versions in 2026.
- Third-party "async" utilities for trivial concurrency —
Promise.all and asyncio.gather cover 95% of cases.
- Using async in tight CPU loops to "be modern" — it adds scheduling overhead with no benefit.
FAQ
Does async/await use multiple threads?
No. In Node.js and Python asyncio, all your code runs on one thread. The event loop interleaves I/O waits. For true parallelism, use worker threads or processes.
When should I use Promise.race?
When you want the first result and are okay discarding the others — e.g., a timeout race: Promise.race([fetchData(), timeout(3000)]).
Can I await in a forEach loop?
Array.forEach does not await each callback. Use for...of with await, or Promise.all with .map.
How do I debug a hanging async function?
Node.js --inspect and Python's asyncio.get_event_loop().set_debug(True) both surface slow callbacks. Look for uncaught rejections and un-awaited coroutines.
Where to go next
See Promises explained in 2026, Error handling explained in 2026, and Closures explained in 2026.