The question this answers
What actually happens at an await, and what may have changed by the time the next line runs?
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.
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.
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.
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.
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.
| # | Checkout task | Cart-edit task | Payment provider | State |
|---|---|---|---|---|
| 1 | reads cart → [widget 40, cable 10]; total = 50 | · | · | cartTotal=50 charged=0 orders=0 |
| 2 | await 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 returns | cartTotal=40 charged=50 orders=0 |
| 5 | resumes; re-reads cart to build the order → [widget 40] | · | · | cartTotal=40 charged=50 orders=0 |
| 6 | writes 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. |
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.
1async function checkout(sessionId: string) {2 const cart = carts.get(sessionId)! // read3 const total = price(cart) // decide4 const auth = await psp.authorise(total) // ← yield point: the world moves5 const fresh = carts.get(sessionId)! // re-read disagrees with the charge6 await orders.insert({ sessionId, lines: fresh.lines, charged: auth.amount })7}1const inFlight = new Set<string>()2 3async function checkout(sessionId: string) {4 // check-and-set with no await between them: atomic on this loop5 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 us9 const total = price(snapshot)10 const auth = await psp.authorise(total) // ← yield point, but nothing we rely on can change11 if (revisionOf(carts.get(sessionId)!) !== snapshot.revision) {12 await psp.void(auth.id) // the cart moved; undo rather than mis-charge13 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 forever18 }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
awaitsuspends one logical task, not the process: control returns to the scheduler and other work runs before your next line.- Every
awaitis 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
awaitis 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.
- • The expression after
awaitis 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
finallyblocks are the only reliable place to release anything you claimed.
- • 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 beforeinFlight.add(id); B checks → still false; both proceed — moving theaddafter any await reintroduces the exact race the claim was meant to prevent. - • A awaits authorise; the request times out and A throws; the
finallyreleases the claim; B's retry proceeds correctly — the schedule that works, and only because of thefinally. - • 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.
- • Guaranteed: a statement sequence containing no
awaitis 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/finallyaround 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.
awaitsays "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.
- • 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.
- • 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.
- • 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.
- • CPU-bound work, where there is nothing to suspend on and
asynconly 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.
- • 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.
- • 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.
- • 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 inidempotency-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
| # | Task A — counter++ | Task B — counter++ | State |
|---|---|---|---|
| 1 | rA ← counter | · | counter=0 rA=0 rB=0 |
| 2 | rA ← rA + 1 | · | counter=0 rA=1 rB=0 |
| 3 | counter ← rA | · | counter=1 rA=1 rB=0 |
| 4 | · | rB ← counter | counter=1 rA=1 rB=1 |
| 5 | · | rB ← rB + 1 | counter=1 rA=1 rB=2 |
| 6 | · | counter ← rB | counter=2 rA=1 rB=2 |
The lost update, step by step
| # | Task A — counter++ | Task B — counter++ | State |
|---|---|---|---|
| 1 | rA ← counter | · | counter=0 rA=0 rB=— |
| 2 | · | rB ← counter | counter=0 rA=0 rB=0 |
| 3 | rA ← rA + 1 | · | counter=0 rA=1 rB=0 |
| 4 | counter ← rA | · | counter=1 rA=1 rB=0 |
| 5 | · | rB ← rB + 1 | counter=1 rA=1 rB=1 |
| 6 | · | counter ← rB | counter=1 rA=1 rB=1 ✕ 2 increments completed, counter = 1 |
What people believe, and what is true
await pauses execution, so the code around it is atomic.
It pauses *this task*. The scheduler immediately runs other tasks, including another copy of the same handler on the same key.
Adding await makes an operation start.
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.
If I re-read the state after awaiting, I am safe.
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.