Async & Event Loops

Await Is a Yield Point

The syntax reads like blocking code; the execution model is start → suspend → return control → other work runs → resume. Everything you must reason about lives in the gap: state can change across an await, and the code around it stops being atomic the moment you add one.

▶ Run the lab

The question this answers

The question

What actually happens at an await, and what may have changed by the time the next line runs?

The work

A cart checkout handler that reads the cart, awaits a payment authorisation, and then writes the order — where the same user may click "pay" twice.

What is shared

The in-memory cart object for that session, plus an orders table row. Both are reachable from every other handler on the same loop, and both are read before the suspension and written after it.

The invariant — what must stay true under every interleaving

Exactly one order exists per authorised payment, and the cart that was priced is the cart that was charged.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The five steps the syntax hides

Written out, await f() does five things: it calls f() and starts the operation; it packages the rest of the current function as a continuation; it returns control to the scheduler; the scheduler runs whatever else is ready; and when f()'s result is available the continuation is enqueued and eventually resumed with the local variables restored.

Steps three and four are the ones the syntax hides, and they are the entire concurrency content of the feature. await does not pause the world; it pauses *this logical task*. Nothing about the surrounding function is protected while it is suspended. In C++ terms this is a coroutine suspension, in Python it is a yield through the coroutine chain to the loop, in JavaScript it is a microtask continuation — three mechanisms, one semantic.

A useful reading habit: mentally draw a horizontal line at every await in a function and ask "what could have run between these two lines, and does it touch anything I read above the line?" That question is the whole discipline. See Coroutines: Functions That Can Pause for the suspension mechanism and Reasoning About Races: A Method, Not an Instinct for how to enumerate the answers.

One handler across a suspension. The lane it vacates is not idle — that is the point.ILLUSTRATIVE
Task A — checkout
read cart, price it
suspended at await authorise()
resume: write order
Task B — cart update (same session)
queued
runs: removes an item from the cart
done
Event loop
A
B
other callbacks
A resumed
↑ yield point↑ resume — locals restored, world changed
runningreadywaitingblockedidle1 tick ≈ 10 ms of wall clock; spans are shape, not measurement

The schedule that charges for the wrong cart

Reading the cart, awaiting authorisation, and writing the order looks like a transaction and is not one. The interleaving below is the ordinary one, not an exotic one: it needs only a second request touching the same session while the first is out at the payment provider.

Notice what is *not* the bug. There is no data race — one loop, no simultaneous memory access. There is no missing mutex over the cart object. The bug is that a decision was made at t=0 and applied at t=10 with nothing carrying it across the gap. Fixes are the ones you would use for any check-then-act: capture an immutable snapshot before suspending and validate it on resume, or take a per-session claim that the second request fails to acquire, or push the atomicity into storage with a conditional write. All three appear again in Optimistic Concurrency Control and Initialization Races.

Double-submit on one session. The invariant is broken by an edit, not by a duplicate.ILLUSTRATIVE
Invariant · The cart that was priced is the cart that is charged, and one order exists per authorisation
#Checkout taskCart-edit taskPayment providerState
1reads cart → [widget 40, cable 10]; total = 50··cartTotal=50 charged=0 orders=0
2await authorise(50) — suspends, control returns to the loop··cartTotal=50 charged=0 orders=0
3·removes cable; cart → [widget 40]·cartTotal=40 charged=0 orders=0
4··authorises 50 and returnscartTotal=40 charged=50 orders=0
5resumes; re-reads cart to build the order → [widget 40]··cartTotal=40 charged=50 orders=0
6writes order with lines [widget 40], amount charged 50··cartTotal=40 charged=50 orders=1
✕ The customer was charged 50 for an order that records 40. The priced cart and the charged cart are different objects in time, and no code compared them.
Re-reading after the await does not fix it and often makes it worse — now the two halves disagree instead of merely being stale. Carry a snapshot (an immutable cart plus its hash) across the suspension and refuse to write the order if the hash no longer matches, or claim the session before authorising so the edit is rejected.

Before and after: keep the decision and the write together

The mechanical fix is to shrink what crosses the yield point. Anything decided before the await must either be re-validated after it or be made unable to change. Both are cheap; neither happens by accident.

The version below claims the session synchronously — there is no await between the check and the set, so on one event loop that pair is atomic — and it validates the snapshot on resume. The claim is a lock; call it one. Its cost is a lifecycle: it must be released in a finally, or a crashed handler wedges that session until the process restarts, which is exactly the failure mode Deadlock describes in a different costume.

Decision at t=0, write at t=10, nothing carried across
1async function checkout(sessionId: string) {
2 const cart = carts.get(sessionId)! // read
3 const total = price(cart) // decide
4 const auth = await psp.authorise(total) // ← yield point: the world moves
5 const fresh = carts.get(sessionId)! // re-read disagrees with the charge
6 await orders.insert({ sessionId, lines: fresh.lines, charged: auth.amount })
7}
Claim synchronously, snapshot across the gap, validate on resume
1const inFlight = new Set<string>()
2
3async function checkout(sessionId: string) {
4 // check-and-set with no await between them: atomic on this loop
5 if (inFlight.has(sessionId)) throw new Conflict('checkout already in progress')
6 inFlight.add(sessionId)
7 try {
8 const snapshot = freeze(carts.get(sessionId)!) // immutable; cannot be edited under us
9 const total = price(snapshot)
10 const auth = await psp.authorise(total) // ← yield point, but nothing we rely on can change
11 if (revisionOf(carts.get(sessionId)!) !== snapshot.revision) {
12 await psp.void(auth.id) // the cart moved; undo rather than mis-charge
13 throw new Conflict('cart changed during authorisation')
14 }
15 await orders.insert({ sessionId, lines: snapshot.lines, charged: auth.amount })
16 } finally {
17 inFlight.delete(sessionId) // release, or this session is wedged forever
18 }
19}

The claim makes the second concurrent checkout fail fast instead of interleaving; the frozen snapshot makes the priced cart unable to change; the revision check turns a silent mis-charge into an explicit conflict the caller can retry. The cost is a lock with a lifetime you now own — and a finally you must never remove.

Key points

  • await suspends one logical task, not the process: control returns to the scheduler and other work runs before your next line.
  • Every await is a yield point. Draw a line there and ask what could have run and what it touched.
  • A check and its dependent write separated by an await is a check-then-act race, on one thread, with no data race involved.
  • Re-reading state after the suspension does not fix staleness; it replaces a stale decision with two halves that disagree.
  • The three fixes are: claim before suspending, snapshot immutably across the gap, or push atomicity into storage with a conditional write.
  • Any claim you take is a lock and needs a finally; a handler that throws without releasing wedges that key until restart.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • The expression after await is evaluated immediately and the operation starts — the suspension happens after the call, not before it.
  • The compiler or runtime splits the enclosing function at that point into a state machine; locals are moved to the heap so they survive the suspension.
  • Control returns to the scheduler, which is free to run any other ready task, including another instance of this same function.
  • When the awaited value settles, the continuation is scheduled — as a microtask in JS, as a callback on the loop in asyncio, as a resumption of the coroutine frame in C++.
  • The continuation resumes with locals restored and everything else exactly as the rest of the program left it.
  • If the awaited operation rejects or throws, the exception is re-raised at the await site, which is why finally blocks are the only reliable place to release anything you claimed.
Interleavings that matter
  • A reads cart (total 50); A awaits authorise; B removes an item (total 40); A resumes and writes an order for 40 while 50 was charged — the priced cart and the charged cart are different.
  • A checks inFlight.has(id) → false; A awaits something before inFlight.add(id); B checks → still false; both proceed — moving the add after any await reintroduces the exact race the claim was meant to prevent.
  • A awaits authorise; the request times out and A throws; the finally releases the claim; B's retry proceeds correctly — the schedule that works, and only because of the finally.
  • A awaits; the process receives a shutdown signal and stops the loop; A's continuation is never scheduled and the payment is authorised with no order recorded — suspension is also where cancellation and shutdown bite. See Draining a Pipeline.
  • A and B both await the same downstream promise; both continuations are queued as microtasks and run back-to-back before any timer — so "concurrent" resumptions are still strictly ordered, and the second sees the first's writes.
What it guarantees — and does not
  • Guaranteed: a statement sequence containing no await is atomic with respect to other tasks on the same loop.
  • Guaranteed: your local variables are exactly as you left them when the continuation resumes.
  • Guaranteed: exceptions from the awaited operation surface at the await site, so try/finally around a suspension does run.
  • NOT guaranteed: that anything reachable through a reference — a Map entry, an object field, a database row — is unchanged.
  • NOT guaranteed: that the continuation runs at all. A stopped loop, a cancelled task or a process exit simply drops it.
  • NOT guaranteed: any ordering relative to other tasks. await says "later", never "next".
  • NOT guaranteed: that awaiting makes the operation start later — in JavaScript the promise is already running by the time you await it.
Where contention appears
  • The suspension itself is contention-free; the resumption competes with every other ready continuation for the loop.
  • A per-key claim (inFlight) converts contention into fast failure rather than waiting — which is usually what an HTTP handler wants, since the client can retry.
  • If you build a real queue instead of a claim, the wait becomes unbounded unless you bound the queue; that is Bounded vs Unbounded Queues arriving by a side door.
  • Awaiting inside a loop over N items serialises N round trips — the single most common accidental contention in async code, covered in The Sequential Await Trap.
How it fails
  • Race condition across a suspension: lost update, double-submit, check-then-act, mis-charge.
  • Stale snapshot applied blindly: the decision from before the gap is written after it with no validation.
  • Leaked claim: a handler throws or is cancelled without a finally, and the key is locked out permanently — a deadlock with one participant.
  • Forgotten await: the promise floats, errors become unhandled rejections, and the caller returns before the work happens. See Orphaned Tasks.
  • Cancellation gap: the task is cancelled while suspended and the external side effect it already started is never undone.
  • Exception swallowed by a rejected promise nobody awaits, so the failure appears as missing data rather than as an error.
When it helps
  • I/O-bound work, where the suspension is genuine waiting and the loop has other things to do with the time.
  • Sequential-looking code over inherently asynchronous operations — the readability win over callback nesting is real and worth a lot.
  • Fan-out where the operations are independent: start them all, then await the collection (Promise.all & gather).
  • Cancellation-aware code, because a suspension point is the natural place for a runtime to deliver a cancellation.
When it hurts
  • CPU-bound work, where there is nothing to suspend on and async only adds a state machine; see Async Is Not Parallelism.
  • Code holding invariants across the gap without saying so — the more state a function reads before an await, the more surface the interleaving has.
  • Hot paths where the per-await allocation and microtask scheduling are measurable against the work being done.
  • Debugging: a stack trace from inside a continuation may show none of the frames that led there.
How you would know
  • Count conflicts, not just errors: a counter for "claim already held" tells you how often the interleaving is actually happening in production.
  • Assert the invariant in code — order amount versus charged amount, admitted count versus allowance — and alert on the assertion, because latency graphs will never show this.
  • Time from suspension to resumption per await site; a large gap on a fast dependency means the loop was busy, not the dependency.
  • Unhandled-rejection count. It is the cheapest proxy for "somebody forgot an await" and it is usually not on the dashboard.
  • Stress the schedule deliberately: inject a random delay at each await point in a test build and run the double-submit path a few thousand times. See Stress Testing: A Test That Passed Once Proves Nothing.
Complexity it introduces
  • Async colours the call graph: a function that awaits forces every caller to await, and retrofitting that through a codebase is a large mechanical change.
  • You now maintain, by hand, the knowledge of which state must survive which gap — the type system tracks none of it.
  • Claims and in-flight maps are locks with bespoke lifecycles; each one is an opportunity for a leak, and none of them show up in a thread dump.
  • Testing requires forcing interleavings rather than observing them, so the test suite grows a scheduling harness or the bug ships.
Simpler alternatives
  • Do not suspend inside the critical region: compute everything, then perform one atomic write. Cheapest fix, available more often than people expect.
  • Let storage own the atomicity — a conditional UPDATE, a unique constraint on an idempotency key, or a compare-and-set on a version column. See Optimistic Concurrency Control and the API-side contract in idempotency-keys.
  • Serialise per key with a single-consumer queue: one task at a time for that session, no interleaving to reason about, at the cost of throughput on hot keys.
  • Threads with a real mutex, when the language and workload suit them and the team would rather hold a lock than reason about yield points — Event Loop or Threads?.

Two increments, twenty schedules: find the one that loses an update

Two increments, twenty schedules
Both tasks run counter++ on the same variable. Drive the schedule yourself: read, add, write are three separate steps, and the scheduler may cut between any two of them.
6/6 steps
counter
2
increments completed
2
rA / rB
1 / 2
invariant
holds
Invariant · after k completed increments, counter === k. No update is lost.
#Task A — counter++Task B — counter++State
1rA ← counter·counter=0 rA=0 rB=0
2rA ← rA + 1·counter=0 rA=1 rB=0
3counter ← rA·counter=1 rA=1 rB=0
4·rB ← countercounter=1 rA=1 rB=1
5·rB ← rB + 1counter=1 rA=1 rB=2
6·counter ← rBcounter=2 rA=1 rB=2
counter = 2, and both callers are right. This schedule happens to be safe because one task finished entirely before the other started. Safe once is not safe: press "Enumerate all" to see how many of the possible schedules do not. Testing samples this space; it does not cover it.
SIMPLIFIEDcounter++ modelled as three indivisible steps. Real compilers and CPUs can split it further, or fuse it into one atomic instruction.

The lost update, step by step

The lost update, step by step
One fixed schedule of two concurrent increments. Nothing to choose — watch where the invariant dies, and where the cause actually was.
1/6 · A · rA ← counter
Invariant · after k completed increments, counter === k. No update is lost.
#Task A — counter++Task B — counter++State
1rA ← counter·counter=0 rA=0 rB=—
2·rB ← countercounter=0 rA=0 rB=0
3rA ← rA + 1·counter=0 rA=1 rB=0
4counter ← rA·counter=1 rA=1 rB=0
5·rB ← rB + 1counter=1 rA=1 rB=1
6·counter ← rBcounter=1 rA=1 rB=1
✕ 2 increments completed, counter = 1
step
1 of 6
counter
0
increments completed
0
invariant
holds
A reads 0. Correct at this instant, and about to stop being correct. A read-modify-write is a window, not an instant. It stays open from the read to the write.
SIMPLIFIEDOne of twenty possible interleavings of this program, chosen because it fails.

What people believe, and what is true

Claim

await pauses execution, so the code around it is atomic.

Reality

It pauses *this task*. The scheduler immediately runs other tasks, including another copy of the same handler on the same key.

Claim

Adding await makes an operation start.

Reality

In JavaScript the operation started when the promise was constructed. await only decides when *you* observe it — which is why const p = f(); await g(); await p runs f and g concurrently.

Claim

If I re-read the state after awaiting, I am safe.

Reality

You have replaced a stale decision with two halves that can disagree. Safety needs a snapshot you can validate, or a claim taken before the suspension.

Go deeper

Overview

Start the operation, package the rest of the function as a continuation, hand control back, and resume later with locals restored and the world changed.

Practical

Never split a check from its dependent write across an await. Claim first, snapshot immutably, validate on resume, release in finally.

Advanced

Eagerness differs by language and changes what "concurrent" means. await a(); await b() is sequential everywhere; const pa = a(), pb = b(); await pa; await pb is concurrent in JS and still sequential in Python unless you wrap each in create_task.

Internals

The function is compiled into a resumable state machine: a heap-allocated frame holding the locals plus a resume index. C++ makes this explicit with the coroutine frame and customisation points; JS and Python hide it, but the allocation is the same and it is why deep await chains cost memory per in-flight task.

Apply it