DEV Community

r9v
r9v

Posted on

await doesn't mean 'meanwhile': the bug every JavaScript engineer writes in Python

There's a bug you'll write in your first week of Python async, and you'll write it precisely because you're good at JavaScript.

In JavaScript, this is a concurrency idiom:

const userPromise = fetchUser(id); // starts now
const ordersPromise = fetchOrders(id); // starts now, runs alongside
const user = await userPromise; // both already in flight
const orders = await ordersPromise;
Enter fullscreen mode Exit fullscreen mode

Call early, await late. The two fetches overlap, total time is roughly the slower of the two, and every JS engineer has this pattern in muscle memory because it's the cheap way to get parallel I/O without reaching for Promise.all. It works because calling an async function in JavaScript starts it immediately. The body runs synchronously up to the first await, the network request is dispatched before the call even returns, and what you get back is a promise that's already hot. By the time you await it, you're not starting anything, you're subscribing to something that's been running the whole time.

Now you learn Python, you meet async def, and you translate the idiom one line at a time:

user_coro = fetch_user(id)
orders_coro = fetch_orders(id)
user = await user_coro
orders = await orders_coro
Enter fullscreen mode Exit fullscreen mode

It runs and returns the right data, raises no error and logs no warning, and it's exactly as slow as running the two fetches one after the other, because that's what it does. Nothing started at the call. fetch_user(id) executed none of the function body, it built a coroutine object and handed it to you inert, and the first line of fetch_user ran only when the await forced it to. Your second fetch sat frozen until the first one finished end to end. The idiom that meant "meanwhile" in JavaScript means "later, in order" in Python, and the syntax is close enough that nothing about the code tells you the meaning changed.

Eager promises, lazy coroutines

The split is in what a call does. A JavaScript async function is eager: invoke it and the work begins, unconditionally, whether or not anyone ever awaits the result. A Python async def function is lazy: invoke it and you get a coroutine object, which is best understood as a frame that hasn't started, the function's code and arguments packaged up with an instruction pointer parked before the first line. Python will happily let you create one with no event loop running at all, pass it around, store it in a list, and none of that executes anything. The only warning the runtime ever gives you is if you throw one away without awaiting it ("coroutine 'fetch_orders' was never awaited"), and notice that our buggy translation never triggers it, since we do await both. Awaited-but-sequentialized is invisible.

await differs to match. In JavaScript, await promise means "suspend me until that already-moving thing completes." In Python, await coroutine means "run this now, as part of me." Mechanically it's delegation, the direct descendant of the generator yield from, and the awaiting coroutine and the awaited one fuse into a single call stack that suspends and resumes as a unit. The event loop never sees your fetch_orders as an independent piece of work it could interleave with something else. From the loop's point of view there was only ever one runnable thing on your behalf, so there's nothing to overlap. That's the whole bug: in JS the concurrency happened at the call, and awaiting late just collected it, while in Python the call created potential and the await is where all the actual execution lives, one at a time, in the order you wrote them.

Tasks are the eager thing

Python does have JavaScript's hot promise, it just makes you ask for it. Wrapping a coroutine in a Task schedules it on the event loop as an independent unit, and from that moment it runs whenever the loop gets control, whether you're awaiting it or not:

user_task = asyncio.create_task(fetch_user(id))
orders_task = asyncio.create_task(fetch_orders(id))
user = await user_task # both in flight since creation
orders = await orders_task
Enter fullscreen mode Exit fullscreen mode

This is the honest translation of the JavaScript original, overlap and all. The rough mapping, if you want it as a table:

JavaScript Python
calling fetchUser(id) asyncio.create_task(fetch_user(id))
a pending Promise a Task
await promise await task
Promise.all([...]) asyncio.gather(...) or TaskGroup
no equivalent a bare coroutine object

The bottom row is the one to internalize. JavaScript has no value that means "an async computation, defined but not started," the closest you get is a thunk like () => fetchUser(id), which is why libraries that need laziness (retry helpers, React Query's queryFn) all take functions rather than promises. Python hands you that lazy value as the default, and the eager one costs a wrapper.

For the fan-out case, gather accepts bare coroutines and wraps each in a Task internally, which is why it delivers real concurrency while your await-in-a-loop doesn't:

results = [await process(item) for item in items] # sequential, N round-trips end to end
results = await asyncio.gather(*(process(item) for item in items)) # concurrent
Enter fullscreen mode Exit fullscreen mode

And since 3.11 the structured version is TaskGroup, which scopes the tasks to a block and refuses to let them outlive it:

async with asyncio.TaskGroup() as tg:
    user_task = tg.create_task(fetch_user(id))
    orders_task = tg.create_task(fetch_orders(id))
user = user_task.result()
orders = orders_task.result()
Enter fullscreen mode Exit fullscreen mode

Why Python went lazy

It's tempting to read the laziness as a wart, but it's a position in a real design argument, and Python's side of it has aged well.

Part of it is lineage. Python coroutines grew out of generators, which are lazy by nature, and async/await (3.5, 2015) was largely new syntax over that machinery, so a coroutine inherited the generator's character: a computation as a value, created anywhere, driven by whoever holds it. A JavaScript promise needs the runtime's machinery the moment it's created; a Python coroutine object doesn't even need a running event loop to exist, which is part of why the same async def can be driven by asyncio, trio, or anything else that speaks the protocol.

The rest is a stance about implicit background work. An eager promise is a fire-and-forget by default, execution that continues whether or not anyone is watching, and JavaScript spent years growing machinery to cope with the consequences, unhandled rejection tracking being the obvious one. Even the call-early-await-late idiom itself has a sharp edge in JS: if the first await throws, the second promise is now rejecting with nobody listening. Python's choice means no work starts implicitly, and anything that should run in the background must be named as a Task, which gives it a lifecycle you're forced to think about. That thinking matters, because a Task you create and drop can be garbage-collected mid-flight, a genuinely nasty gotcha that deserves its own post, and TaskGroup exists precisely to make task lifetimes structural rather than something you track by discipline.

The production shape of the bug

What makes this one dangerous is that it produces no failure, only a latency signature. The endpoint returns correct data, the tests that check payloads pass, and the only symptom is that a request which fans out to four downstream calls takes the sum of their latencies instead of the max. If those calls are 50ms each, you shipped a 200ms endpoint that should be a 50ms one, and nothing in your logs or your test suite will ever flag it. It just sits there as baseline slowness that everyone attributes to "Python being slow" or the network, and it survives because the code reads correctly to anyone whose async instincts were trained in JavaScript, which in a lot of teams is everyone.

The audit is mechanical enough to do on review. Any time two awaits appear in sequence, ask whether the second call actually needs the first one's result. If it does, the sequence is honest and there's nothing to fix. If it doesn't, the awaits are serializing work that could overlap, and the fix is gather, a TaskGroup, or explicit create_task, whichever fits the structure. The deeper habit is to stop reading await as "meanwhile, collect this" and start reading it as "run this here," because in Python the await isn't a checkpoint where concurrency you already launched comes home, it's the place in the program where the work happens. Once that reading settles in, the lazy model stops feeling like a trap and starts feeling like what it is, a language handing you computations as values and letting you decide, explicitly, which of them get to run at the same time.

Top comments (0)