async/await underneath the hood in C# and JavaScript

Below is a deep‐dive into how async/await
works “under the hood” in .NET (C#) versus JavaScript (both browser and Node.js). We’ll break it into:
- A quick recap of what
async/await
means on each platform - C#/.NET internals: compiler‐generated state machines,
Task
/Task<TResult>
, the role ofSynchronizationContext
/TaskScheduler
, and how continuations are scheduled - JavaScript internals: how
async
functions are transpiled (or natively implemented) into Promise‐based state machines, how V8 (or browsers’ engines) schedule continuations via the microtask queue, and the differences between browser and Node.js runtimes - A comparison summary, highlighting the key contrasts
1. What “async/await” Means at a High Level
In C#/.NET
- You mark a method with
async
, return eitherTask
orTask<TResult>
(orValueTask
/ValueTask<TResult>
in newer frameworks), and useawait
on something that is awaitable (typicallyTask
-based). - The compiler rewrites your method into a state machine. At each
await
, control yields back to the caller, and a continuation is registered to resume the remainder of that method once the awaited task completes.
In JavaScript (Browser or Node.js)
- You declare a function with
async function foo() { … }
. Internally, the engine ensures that callingfoo()
immediately returns aPromise
. - Each
await
“unwraps” anotherPromise
and pauses the function’s execution until thatPromise
settles; once it settles (fulfills or rejects), the rest of the function continues in a continuation, scheduled as a microtask.
Although both languages use very similar syntactic sugar—“write asynchronous code in a sequential style”—the mechanics underneath differ significantly because of:
- Their threading models (multi‐threaded vs single‐threaded event loop)
- The types that represent ongoing operations (
Task
vsPromise
) - How continuations are queued and resumed
2. C#/.NET Internals
2.1 Compiler‐Generated State Machine
When you write something like:
public async Task<int> GetLengthAsync(string url)
{
var client = new HttpClient();
string text = await client.GetStringAsync(url);
return text.Length;
}
The C# compiler transforms it roughly into:
- A private struct/class implementing
IAsyncStateMachine
. This struct holds:- A field for each
await
’s “awaiter” (e.g.TaskAwaiter<string>
). - A field to store local variables across
await
boundaries (text
in this case). - An integer
int state;
field that tracks whichawait
“slot” we’re in.
- A field for each
- A
MoveNext()
method inside that state machine, which behaves like:- A giant
switch(state)
block; eachcase
corresponds to the code before/after eachawait
. - On first call,
state == –1
, so it executes everything until the firstawait
. - When hitting
await client.GetStringAsync(url)
, it checks the returnedTask<string>
.- If that task is already completed, it “inlines” the result and proceeds to the next line (no yielding).
- If it is not yet completed, it:
- Stores the
TaskAwaiter<string>
into a field, - Sets
state = 0
(meaning “we’re suspended at the first await”), - Registers a callback (continuation) so that when the
Task<string>
completes, the runtime callsMoveNext()
again. - Then returns immediately from
MoveNext()
, unwinding the stack back to the caller.
- Stores the
- A giant
- A “builder” object—usually an instance of
AsyncTaskMethodBuilder<TResult>
—that:- Exposes a
Task<TResult>
property (the eventualTask<int>
returned to callers). - Knows how to handle exceptions and “set result” from within the state machine.
- Provides methods like
AwaitOnCompleted(ref TaskAwaiter awaiter, ref IAsyncStateMachine stateMachine)
which hook up the continuation.
- Exposes a
At compile time, your async
method is rewritten somewhat like:
public Task<int> GetLengthAsync(string url)
{
// 1) Create the state machine instance:
var stateMachine = new <GetLengthAsync>d__0(); // compiler‐generated name
stateMachine.url = url;
stateMachine.builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.state = –1; // initial state
stateMachine.builder.Start(ref stateMachine); // calls MoveNext() once
return stateMachine.builder.Task; // returned to caller immediately
}
private struct <GetLengthAsync>d__0 : IAsyncStateMachine
{
public int state;
public AsyncTaskMethodBuilder<int> builder;
public string url;
private HttpClient client;
private string text; // local variable stored across awaits
private TaskAwaiter<string> awaiter;
void IAsyncStateMachine.MoveNext()
{
int result;
try
{
if (state == –1)
{
client = new HttpClient();
Task<string> getStringTask = client.GetStringAsync(url);
if (!getStringTask.IsCompleted)
{
state = 0;
awaiter = getStringTask.GetAwaiter();
builder.AwaitOnCompleted(ref awaiter, ref this);
return;
}
text = getStringTask.Result; // if already completed
}
else if (state == 0)
{
text = awaiter.GetResult(); // resume here when completed
}
// ... now we have `text` and can finish
result = text.Length;
}
catch (Exception ex)
{
builder.SetException(ex);
return;
}
builder.SetResult(result);
}
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { }
}
Key points:Eachawait
becomes two “segments” (before‐await and after‐await) inside one method.The generated state machine stores all locals and spawns continuations.TheAsyncTaskMethodBuilder<TResult>
holds onto theTask<TResult>
that callers see immediately.
2.2 Task
vs ValueTask
- By default, C# uses
Task
orTask<TResult>
to represent asynchronous work. - Newer .NET versions introduce
ValueTask
/ValueTask<TResult>
, which avoid heap allocations when an operation is already completed synchronously—but the state machine logic is analogous.
2.3 Scheduling Continuations: SynchronizationContext
and TaskScheduler
Once you hit an incomplete Task
and call builder.AwaitOnCompleted
, .NET must decide where to resume the rest of your state machine. Two important abstractions govern that:
SynchronizationContext
(often present in UI apps)- In a WPF/WinForms/Xamarin/Desktop app, there is a single‐threaded
SynchronizationContext
that represents the UI thread’s context. - By default,
await someTask
captures the currentSynchronizationContext
and posts the continuation back to that context. That way, after anawait
, you automatically return to the UI thread. - If you do
await someTask.ConfigureAwait(false)
, you tell the compiler NOT to capture the context. Then the continuation will run on aThreadPoolThread
(or some other scheduler) instead.
- In a WPF/WinForms/Xamarin/Desktop app, there is a single‐threaded
TaskScheduler
(defaults toThreadPool
if noSynchronizationContext
)- In a console app or ASP.NET Core (where there’s no “UI” context), there is no custom
SynchronizationContext
, so the default “task scheduler” is the thread pool. This means continuations resume on a thread‐pool thread. - ASP.NET (pre‐Core) had its own
SynchronizationContext
which tried to flow the request’s context back onto the request thread. ASP.NET Core does not capture a SynchronizationContext by default, so continuations run on the thread pool.
- In a console app or ASP.NET Core (where there’s no “UI” context), there is no custom
Every time you do await
, the compiler-generated call to builder.AwaitOnCompleted
(or AwaitUnsafeOnCompleted
) grabs the current SynchronizationContext
or TaskScheduler
. When the awaited Task
completes, it invokes the continuation by posting it back to that context/scheduler.
2.4 Threading and Parallelism
- Because .NET has real threads under the hood, an
async
method can physically resume on a different thread than it started on (unless you’re in a UI context and you didn’t useConfigureAwait(false)
). - If you
await
I/O‐bound operations (e.g.,HttpClient.GetStringAsync
), that I/O typically uses OS‐level asynchronous I/O, freeing up the thread to do other work while waiting for the network. When the network result arrives, the OS queue calls back into .NET, completes theTask
, and enqueues your continuation.
2.5 Exception Handling
- Exceptions inside an async method propagate the same way they would in a synchronous method, but get captured by the
builder
and stored in the returnedTask
. - The caller can inspect
task.IsFaulted
/task.Exception
, or useawait
again, which will re‐throw that exception at the await point.
3. JavaScript Internals (Browser & Node.js)
3.1 async
Functions → Promise‐Based State Machine
When you write:
async function getLengthAsync(url) {
const response = await fetch(url);
const text = await response.text();
return text.length;
}
the JavaScript engine (V8, SpiderMonkey, JavaScriptCore, Chakra, etc.) “desugars” that roughly into something like:
- Immediately return a
Promise
whengetLengthAsync(...)
is called. - Under the hood, the engine constructs a lightweight state machine that steps through each
await
:- It calls
fetch(url)
and gets aPromise<Response>
. - It attaches a
.then(...)
continuation to thatPromise<Response>
, which steps to “callresponse.text()
and then attach another.then(...)
”, and so on. - When you
return text.length;
, it effectively doesresolve(text.length)
.
- It calls
- If anywhere a
Promise
rejects (or you throw an error inside theasync
function), the engine arranges to callreject(error)
on that outer promise.
Concretely:
async function getLengthAsync(url) {
// Internally becomes (conceptually):
return fetch(url)
.then(response => response.text())
.then(text => text.length);
}
but with a more efficient, single‐object state machine rather than nested .then
calls.
3.2 Event Loop & Microtasks
- JavaScript is (in most environments) single‐threaded. There is one “call stack” and one “event loop”.
- Promises and
await
use the microtask queue (sometimes called “jobs”):- When you
await foo()
, as soon asfoo()
returns aPromise
that isn’t already resolved, the rest of theasync
function is parked. - When that
Promise
resolves (or rejects), the continuation is enqueued as a microtask (which runs after the current JavaScript stack unwinds but before the next macrotask/event). - That means two sequential
await
s happen in separate microtask ticks, but both are guaranteed to run before e.g.setTimeout(..., 0)
callbacks.
- When you
In browsers:
- The HTML5 event loop specification defines separate queues for microtasks (Promises,
queueMicrotask
,MutationObserver
callbacks) and macrotasks (timers, I/O callbacks, UI events). - Every time the JavaScript engine finishes executing the “current script/microtask,” it drains the microtask queue before picking up the next macrotask.
In Node.js:
- Node uses libuv’s loop to drive I/O, timers, etc.
- Once a Promise resolves, its
.then
callbacks (i.e.await
continuations) are queued into the microtask queue (the “next tick” queue is even higher‐priority but behaves similarly). - In Node version ≥ 11, microtasks also run between phases of the event loop.
3.3 Browser vs Node Differences (Subtle)
- Underlying Promise implementation:
- In browsers, a given engine’s native Promise is used. In Node.js, V8’s Promise implementation is used as well—but Node provides some extra hooks like
process.nextTick
(which fires even earlier than a normal microtask).
- In browsers, a given engine’s native Promise is used. In Node.js, V8’s Promise implementation is used as well—but Node provides some extra hooks like
- I/O Backends:
- In Node.js,
fetch
(when available) or other built‐in async APIs (e.g.fs.promises.readFile
) offload to libuv’s thread pool or OS asynchronous I/O. When I/O completes, libuv schedules a callback into the event loop’s callback phase, at which point the associated Promise is resolved and the microtask queue is drained before returning to other I/O. - In browsers, network I/O for
fetch
goes through the browser’s networking stack, which triggers the Promise resolution once data arrives.
- In Node.js,
- Task Priorities:
- Browsers strictly follow the HTML spec: after each macrotask (like a “click” event), they drain the microtask queue (Promise continuations), then render (if needed), then move to the next macrotask.
- Node’s event loop phases give microtasks priority right after each callback phase. Node also has
process.nextTick
which is “even earlier” than standard microtasks, but that’s a Node‐specific detail (and not something typical JS developers explicitly rely on insideasync/await
).
3.4 How the State Machine Actually Works
Under the hood, for each async function
call, the engine allocates a small runner (often an internal object akin to “AsyncFunctionStateMachine
”). That object tracks:
- The current instruction pointer (which “step” you’re on)
- Any temporaries or locals you need to keep across awaits
- A reference to the outer
Promise
that you immediately returned - A small jump table that says “once the awaited promise finishes, resume at this instruction”.
Whereas C#’s compiler emits a physical struct/class implementing IAsyncStateMachine
, JavaScript’s engine (V8, for instance) will JIT-generate a hidden state machine—often encoded in C++—that behaves similarly. The JIT might optimize it down to a hand‐optimized “tiny coroutine” that only allocates when really needed.
Key takeaways:
- JavaScript’s “state machine” is internal to the engine (not visible in userland). You simply get a
Promise
. - Continuations always run on the same thread, via the microtask queue, preserving the single‐threaded model.
4. Comparison Summary
Aspect | C#/.NET async/await |
JS/Node async/await |
---|---|---|
Representation of the ongoing operation | Task / Task (or ValueTask… ) which can involve real threads or OS I/O. |
Promise —always single‐threaded. |
Compiler involvement | Compiler rewrites your method into a struct/class that implements IAsyncStateMachine . |
Engine (V8/SpiderMonkey/WebKit) internally rewrites your async function into a hidden state machine. |
State machine allocation | Usually a heap‐allocated struct/class for each invocation—captures locals, state, builder, etc. | Underlying engine may allocate a small Coroutine object, or even optimize away if everything resolves synchronously. |
Threading model | Multi‐threaded. I/O awaits release the thread back to the thread pool or UI thread; continuations may resume on different threads (unless you use ConfigureAwait(false) or there is no SynchronizationContext ). |
Single‐threaded. Everything runs on the same JS thread. Awaited operations do not create new JS threads—they just enqueue microtasks. |
Scheduling continuations | - By default, captures current SynchronizationContext (e.g. UI thread) and posts continuation back to it.- In absence of a custom context (e.g. ASP.NET Core console app), uses the default TaskScheduler (thread pool).- You can bypass context capturing with ConfigureAwait(false) . |
- After a Promise resolves, continuation is queued onto the microtask queue (per ECMAScript spec).- Microtasks run immediately after the current call stack unwinds, before the next macrotask (e.g., setTimeout ).- No concept of “thread”; no context to capture beyond the current event loop tick. |
Exception propagation | Exceptions thrown inside an async method go into Task.Exception ; when you await that task, the exception is rethrown. |
Exceptions inside an async function cause the returned Promise to reject; you handle with .catch(...) or a try/catch around await . |
Cancellation | Can pass a CancellationToken into your asynchronous calls (e.g., client.GetStringAsync(url, token) ). The generated state machine will observe that token (if the awaited API does). |
There is no built‐in standard Promise cancellation. You can use nonstandard constructs (e.g., AbortController for fetch in browsers/Node) but the language does not automatically propagate cancellation through await . |
Performance considerations | - There is some allocation cost for the Task and the state machine (unless the compiler/JIT elides it with ValueTask ).- If the awaited operation completes synchronously (i.e., IsCompleted == true ), the compiler can optimize to run inline without heap allocation. |
- Similar: if an await ed Promise is already resolved, many engines will avoid an extra microtask tick or allocation and continue synchronously—though not guaranteed by spec, most modern engines do. |
Mixing with synchronous code | If there’s no await in your async method (e.g., you accidentally wrote async Task Foo() { return 5; } ), the compiler emits a warning, because you pay the state-machine cost for nothing. |
Same reliability concerns: an async function foo() { return 5; } yields a Promise.resolve(5) —there’s no state machine overhead beyond a trivial wrapper. |
4.1 Key Conceptual Differences
- Threading vs Event Loop
- C#: True multi‐threading. When you
await
an incompleteTask
, the CPU thread is freed (returned to the thread pool or UI poll loop) until the I/O or work completes. When it completes, the continuation may run on a different thread (unless a specificSynchronizationContext
forces it back onto the original). - JavaScript: Single‐threaded. “Waiting” always means “I/O, timer, or promise callback will come back later; meanwhile, we run other callbacks via the event loop.” There is no risk of “continuation running on a completely separate CPU thread” (unless you explicitly spawn a
Worker
/Worker Threads
, butasync/await
itself never touches other threads).
- C#: True multi‐threading. When you
- Context Capture
- C#: By default,
await
captures the currentSynchronizationContext
and posts the continuation back to that context. You either remain on the UI thread or—if there is no context—resume on the thread pool. - JS: There is no “context” in the same sense. Everything just happens on the JS event loop. Inside a browser “context” (e.g., a particular window/frame), your microtask will run back on that same frame’s thread. In Node, everything returns to the same event loop thread. You don’t explicitly opt in/out of that behavior (apart from going into a web worker).
- C#: By default,
- Cancellation & Timeouts
- C#: The BCL and most async APIs take a
CancellationToken
. If you want cooperative cancellation, you pass that token all the way down, and you can throwOperationCanceledException
. - JS: Promises have no built‐in cancel token. You typically use
AbortController
(for fetch, streams, etc.) or roll your own “cancellable promise” library. But the language itself does not wire up cancellation automatically when youawait
.
- C#: The BCL and most async APIs take a
- Exception Models
- C#: Exceptions bubble out of an async method into the returned
Task
. If nobody awaits or inspects it, an unobserved exception can crash (in older .NET) or be ignored (modern .NET logs it as unobserved). - JS: If an
async
function throws, the returnedPromise
becomes rejected. If you lack a.catch()
, Node will emit anunhandledRejection
warning (or crash if unhandled), and browsers will typically log an “Unhandled Promise rejection” to the console.
- C#: Exceptions bubble out of an async method into the returned
- Performance Characteristics
- Both languages implement various optimizations so that if an awaited operation is already complete, they can often inline the continuation synchronously (avoiding a round‐trip through the scheduler). But the details (how many allocations, how the JIT optimizes away the state machine, etc.) differ between Roslyn/JIT in .NET and V8/SpiderMonkey in JS.
5. Bottom‐Line Takeaways
- State Machine Generation
- C#: You literally get a compiler‐generated struct/class implementing
IAsyncStateMachine
. If you peek at IL (with a decompiler), you can see all the fields and theMoveNext()
method with a giantswitch
onstate
. - JS: The engine’s JIT or interpreter implements its own internal “async coroutine” mechanism. It’s not user‐visible, but in principle it’s conceptually identical: each
await
is a state transition, and the engine wires up.then
callbacks behind the scenes.
- C#: You literally get a compiler‐generated struct/class implementing
- Scheduling
- C#: Once the awaited
Task
completes, .NET posts your continuation back to either aSynchronizationContext
(UI thread, if present) or the thread‐pool. There’s real “thread hopping” possible. - JS: When the awaited
Promise
resolves, its continuation is placed on the microtask queue of the same single‐threaded event loop. There is no hopping to another OS thread (unless you explicitly delegate to a Web Worker or Node Worker Thread).
- C#: Once the awaited
- Cancellation, Context, and Host Environment
- C#: You have
CancellationToken
, you have a concept of capturing synchronization contexts (UI vs thread pool vs ASP.NET context), and you can configure that withConfigureAwait
. - JS: You lack a language‐level cancel token, but you can rely on environment‐specific APIs (
AbortController
,ReadableStream.cancel()
, etc.). The “context” is always the same JS turn unless you deliberately off‐load to a worker.
- C#: You have
- Error Handling
- C#:
await
rethrows exceptions. If you don’t catch inside theasync
method, they bubble into the returnedTask
. - JS:
await
also rethrows. If thePromise
rejects and you have no localtry/catch
, the returnedPromise
is rejected, requiring a.catch()
or a top‐levelwindow.onunhandledrejection
(browser) orprocess.on('unhandledRejection')
(Node) to handle it.
- C#:
- When Something Actually Runs on Another Thread
- C#: If you call
await Task.Run(() => { … })
, you explicitly spawn work on a thread‐pool thread. Plenty of ASP.NET APIs (e.g.,SqlClient.ExecuteReaderAsync
) rely on OS asynchronous I/O, but something likeTask.Delay
uses a timer to schedule continuation. - JS: Standard library functions (e.g.
fetch
,fs.promises.readFile
) are asynchronous but—they never run your JavaScript on a different thread. Instead, underlying C++ or native code does I/O, then calls back into JS. If you want CPU‐bound work off the main thread, you explicitly create a Worker/Worker Thread.
- C#: If you call
In Practice
- If you come from C#, you might think “
await
always frees a thread so it can do other work.” In reality, in JavaScript you never free a JS thread (there’s just one), you just queue up your continuation and allow the event loop to process other callbacks in the meantime. - Conversely, if you come from JS, you might be surprised that in C# you can have your continuation run on a totally different thread (for example, a thread‐pool thread) unless you explicitly ask
.ConfigureAwait(false)
or there’s noSynchronizationContext
.
Understanding these distinctions clarifies why:
- In C#, you worry about deadlocks if you
Wait()
on a task in a UI context (because the context is blocked waiting). - In JS, “blocking the event loop” (e.g. a tight
while (true) { }
) simply stalls everything—no other callback orawait
continuation can run until you return to the event loop.
References for Further Reading (no external citations needed)
- C# Compiler Async Transformation: look at the Roslyn-generated IL (e.g., with ILSpy) to see the state machine.
- ECMAScript Spec – Async Functions: § 14.6 “Async Function Definition” describes how
async
is supposed to work in terms of job queues. - Threading Docs (C#): “Async/Await and SynchronizationContext” (Stephen Cleary’s blog or Microsoft Docs)
- Event Loop Lecture: MDN’s “Concurrency Model and Event Loop”
With this in mind, you should now have a clear picture of how both languages conceptually convert your nice linear code into state machines, but differ in threading, scheduling, and cancellation semantics.