async/await underneath the hood in C# and JavaScript

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:

  1. A quick recap of what async/await means on each platform
  2. C#/.NET internals: compiler‐generated state machines, Task/Task<TResult>, the role of SynchronizationContext/TaskScheduler, and how continuations are scheduled
  3. 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
  4. 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 either Task or Task<TResult> (or ValueTask/ValueTask<TResult> in newer frameworks), and use await on something that is awaitable (typically Task-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 calling foo() immediately returns a Promise.
  • Each await “unwraps” another Promise and pauses the function’s execution until that Promise 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:

  1. Their threading models (multi‐threaded vs single‐threaded event loop)
  2. The types that represent ongoing operations (Task vs Promise)
  3. 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:

  1. 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 which await “slot” we’re in.
  2. A MoveNext() method inside that state machine, which behaves like:
    • A giant switch(state) block; each case corresponds to the code before/after each await.
    • On first call, state == –1, so it executes everything until the first await.
    • When hitting await client.GetStringAsync(url), it checks the returned Task<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 calls MoveNext() again.
        • Then returns immediately from MoveNext(), unwinding the stack back to the caller.
  3. A “builder” object—usually an instance of AsyncTaskMethodBuilder<TResult>—that:
    • Exposes a Task<TResult> property (the eventual Task<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.

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:Each await becomes two “segments” (before‐await and after‐await) inside one method.The generated state machine stores all locals and spawns continuations.The AsyncTaskMethodBuilder<TResult> holds onto the Task<TResult> that callers see immediately.

2.2 Task vs ValueTask

  • By default, C# uses Task or Task<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:

  1. 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 current SynchronizationContext and posts the continuation back to that context. That way, after an await, 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 a ThreadPoolThread (or some other scheduler) instead.
  2. TaskScheduler (defaults to ThreadPool if no SynchronizationContext)
    • 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.

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 use ConfigureAwait(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 the Task, 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 returned Task.
  • The caller can inspect task.IsFaulted/task.Exception, or use await 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:

  1. Immediately return a Promise when getLengthAsync(...) is called.
  2. Under the hood, the engine constructs a lightweight state machine that steps through each await:
    • It calls fetch(url) and gets a Promise<Response>.
    • It attaches a .then(...) continuation to that Promise<Response>, which steps to “call response.text() and then attach another .then(...)”, and so on.
    • When you return text.length;, it effectively does resolve(text.length).
  3. If anywhere a Promise rejects (or you throw an error inside the async function), the engine arranges to call reject(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”):
    1. When you await foo(), as soon as foo() returns a Promise that isn’t already resolved, the rest of the async function is parked.
    2. 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).
    3. That means two sequential awaits happen in separate microtask ticks, but both are guaranteed to run before e.g. setTimeout(..., 0) callbacks.

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)

  1. 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).
  2. 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.
  3. 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 inside async/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 awaited 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

  1. Threading vs Event Loop
    • C#: True multi‐threading. When you await an incomplete Task, 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 specific SynchronizationContext 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, but async/await itself never touches other threads).
  2. Context Capture
    • C#: By default, await captures the current SynchronizationContext 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).
  3. 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 throw OperationCanceledException.
    • 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 you await.
  4. 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 returned Promise becomes rejected. If you lack a .catch(), Node will emit an unhandledRejection warning (or crash if unhandled), and browsers will typically log an “Unhandled Promise rejection” to the console.
  5. 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

  1. 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 the MoveNext() method with a giant switch on state.
    • 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.
  2. Scheduling
    • C#: Once the awaited Task completes, .NET posts your continuation back to either a SynchronizationContext (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).
  3. 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 with ConfigureAwait.
    • 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.
  4. Error Handling
    • C#: await rethrows exceptions. If you don’t catch inside the async method, they bubble into the returned Task.
    • JS: await also rethrows. If the Promise rejects and you have no local try/catch, the returned Promise is rejected, requiring a .catch() or a top‐level window.onunhandledrejection (browser) or process.on('unhandledRejection') (Node) to handle it.
  5. 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 like Task.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.

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 no SynchronizationContext.

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 or await 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.