Guide

async and await look like sequential code. The function reads top to bottom, and the await lines wait for results. Underneath, the compiler rewrites the function into a state machine. Each await is a point where the function saves its local variables, returns control to the event loop, and resumes later when the result is ready.

Knowing this explains three things. Why an await in a loop runs one at a time. Why a function that awaits anything must itself be async. And why a forgotten await silently drops work.

The big idea

A plain function runs from start to finish on its stack. An async function can't keep its stack while it waits, because the thread needs to run other tasks. So the compiler turns its locals into fields of an object, and the function becomes a set of resumable states:

  • State 0: start, then call the first async operation and record that the function will resume at state 1.
  • State 1: the result is ready, so store it, call the next operation, and record state 2.
  • And so on, until the function returns.

The event loop stores this object, and calls it again when the awaited operation completes.

Walking through it

Here is a small Python generator that does the same job by hand. A yield plays the role of an await. The loop sends the result back in.

Python
def fetch_user():
    print("state 0: send request")
    user = yield "request_user"            # suspend here, resume with the result
    print("state 1: got", user)
    order = yield f"request_orders for {user}"
    print("state 2: got", order)
    return f"{user} has {order}"

g = fetch_user()
print("loop got:", next(g))
print("loop got:", g.send("alice"))
try:
    g.send(["order_9"])
except StopIteration as stop:
    print("done:", stop.value)

Output:

state 0: send request
loop got: request_user
state 1: got alice
loop got: request_orders for alice
state 2: got ['order_9']
done: alice has ['order_9']

The generator pauses at each yield and keeps its locals. The loop does other work while it waits, then resumes the function with send. Python's async/await uses the same mechanism at the scheduler level.

What this implies

Awaits are sequential by default. Two await lines run one after the other, even when they don't depend on each other. To run them together, start both and then wait for both, such as asyncio.gather. Missing this doubles latency.

Function color. An async function can be called only from another async function, or through a runner. A synchronous function can't await. So async spreads up the call stack, which is why many codebases are async end to end. Pick the style early, and wrap synchronous libraries at the edge.

Forgotten awaits. Calling an async function without await creates a coroutine that never runs. Python warns with "coroutine was never awaited". In JavaScript, a missing await creates a promise nobody handles, and errors can disappear. Turn on lint rules that flag floating promises.

Tip

Start independent calls together, and await them together. This is the cheapest latency fix in most async services.

Common mistakes

  • Awaiting independent operations one after another, when gather or Promise.all would run them together.
  • Mixing blocking calls into async code, which stalls the loop (see event loops).
  • Forgetting await on a call whose result matters, so the error or the write is lost.

Check yourself

  • Why must an async function keep its locals somewhere other than the stack? Answer: it suspends at each await, and the thread must run other tasks. Its locals must survive in a heap-held state object.
  • Why do two sequential awaits take longer than one with gather? Answer: the second operation doesn't start until the first finishes, so their waits add up.

Key takeaways

  • An async function compiles into a state machine, with one state per await.
  • Each await suspends the function and lets the loop run other tasks.
  • Sequential awaits add their latencies. Use gather or Promise.all for independent work.
  • Function color spreads async up the stack, and missed awaits silently drop work.

Further learning

Next, see goroutines and green threads for a model that avoids function color.