Your await Isn’t Broken. Your Mental Model Of It Is.
SynchronizationContext, Channel<T>, and why I’m not touching .NET 10’s Runtime-Async in production yet

Your await Isn’t Broken. Your Mental Model Of It Is.
SynchronizationContext, Channel<T>, and why I’m not touching .NET 10’s Runtime-Async in production yet
I spent an evening a few months back staring at a WinForms grid that froze for four seconds every time a user opened a client’s asset list. Nothing in the code looked wrong. await was there. The method was async. Task.Run wasn't missing. And yet the UI thread sat there, blocked, while a List<Transaction>.OrderBy ran on a few thousand rows.
The bug wasn’t in the async code. It was in the assumption that async automatically means "not on the UI thread." It doesn't. It means "this method can yield." Those are different claims, and the gap between them is where most of the async bugs I've reviewed actually live — in WinForms grids, in ASP.NET Core middleware, and now, potentially, in .NET 10's Runtime-Async preview if people adopt it without understanding what it changes underneath.
What await actually compiles to
Every async method the compiler touches gets rewritten into a state machine — a struct or class implementing IAsyncStateMachine, with a MoveNext() method and a numeric _state field tracking which await you're currently sitting at. When you hit an incomplete await, the compiler doesn't block the thread. It registers a continuation with the awaited task, returns control to the caller, and waits for something to call MoveNext() again when the awaited operation finishes.
That “something” is the part people skip. On a thread pool thread with no context, the continuation runs on whatever thread pool thread happens to be free — could be the same one, could not be. In a WinForms or WPF app, SynchronizationContext.Current returns a WindowsFormsSynchronizationContext or DispatcherSynchronizationContext, and the compiler-generated state machine captures it before the first await and calls Post() on it to marshal the continuation back to the UI thread. That's the entire mechanism that lets you update a Label.Text after an await without an explicit Invoke call — no magic, just a captured context object doing Send/Post on your behalf.
The bug in my grid wasn’t a missing await. It was a synchronous .OrderBy().ToList() sitting after an await, still running on the UI thread because that's exactly where the continuation was supposed to resume. The fix wasn't ConfigureAwait(false) everywhere — in a WinForms app you often want the UI context back. The fix was pushing the actual CPU-bound sort onto Task.Run, so the work left the UI thread, not just the await syntax.
// Wrong: await doesn't move CPU work off the UI thread by itself
private async void RefreshGrid_Click(object sender, EventArgs e)
{
var raw = await _client.GetTransactionsAsync(clientId); // this yields
var sorted = raw.OrderByDescending(t => t.Date).ToList(); // this doesn't
grid.DataSource = sorted;
}
// Right: the CPU-bound part gets its own thread, the UI update stays on the context
private async void RefreshGrid_Click(object sender, EventArgs e)
{
var raw = await _client.GetTransactionsAsync(clientId);
var sorted = await Task.Run(() => raw.OrderByDescending(t => t.Date).ToList());
grid.DataSource = sorted; // resumes on WindowsFormsSynchronizationContext
}
.NET 9 added Control.InvokeAsync, which is the cleaner way to marshal back to the UI thread going forward instead of the older BeginInvoke/Invoke pair, but the underlying mechanism — a captured SynchronizationContext — hasn't changed. ASP.NET Core, by contrast, has run without a SynchronizationContext since the classic AspNetSynchronizationContext was dropped moving off System.Web, which is exactly why ConfigureAwait(false) is close to a no-op there and why deadlock-from-blocking-on-async bugs look completely different in a Web API project than in a desktop one. Same keyword, different runtime behavior, and I've seen developers copy-paste "always use ConfigureAwait(false)" advice from web codebases straight into WinForms and break UI updates.
Channel<T> for the case IAsyncEnumerable can’t cover cleanly
The inspection app I work on processes uploaded photos through an AI damage-detection pipeline — multiple producers (concurrent uploads from field inspectors), one consumer (the detection queue) that needs backpressure so it doesn’t get flooded. IAsyncEnumerable<T> is the right shape for a single async sequence being consumed once. It's the wrong shape for multiple producers writing into a shared buffer with a bounded capacity, which is what System.Threading.Channels.Channel<T> is actually built for.
var channel = Channel.CreateBounded<InspectionPhoto>(new BoundedChannelOptions(capacity: 50)
{
FullMode = BoundedChannelFullMode.Wait
});
// Producer - one per concurrent upload
async Task ProduceAsync(InspectionPhoto photo, CancellationToken ct)
{
await channel.Writer.WriteAsync(photo, ct);
}
// Single consumer draining the queue
async Task ConsumeAsync(CancellationToken ct)
{
await foreach (var photo in channel.Reader.ReadAllAsync(ct))
{
await _damageDetector.AnalyzeAsync(photo, ct);
}
}
The bounded capacity with FullMode.Wait is what gives you backpressure — producers awaiting WriteAsync will actually pause once the channel fills, instead of an unbounded queue quietly eating memory during a busy upload window. This is the part that's easy to get wrong with a naive ConcurrentQueue<T> plus a polling loop: you either burn CPU polling an empty queue or you have no mechanism to slow producers down at all.
Cancellation isn’t a nice-to-have on IAsyncEnumerable
IAsyncEnumerable<T> earns its keep in the reporting side of the same system — streaming a large compliance report row by row instead of materializing the whole thing in memory first. The part that gets skipped in tutorials is [EnumeratorCancellation]:
public async IAsyncEnumerable<ComplianceRow> StreamReportAsync(
int siteId,
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var row in _db.ComplianceRows
.Where(r => r.SiteId == siteId)
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return row;
}
}
Without that attribute, a CancellationToken passed into an async-iterator method sits there unused — the compiler needs the attribute to know to route it into the generated enumerator's MoveNextAsync. I've seen this shipped without the attribute more than once, usually because the method compiled fine and nobody noticed the token wasn't actually stopping anything until a client disconnected mid-stream and the query kept running server-side anyway.
Why I’m holding off on Runtime-Async
.NET 10 shipped an experimental preview of Runtime-Async — a genuinely different compilation strategy where the runtime, not the compiler, handles suspension, producing leaner IL without the generated state machine class. The promise is fewer allocations and faster deep async chains, and long-term that’s probably where the platform ends up.
The catch, as of this preview, is that the framework itself — Kestrel, the JSON serializers, the rest of ASP.NET Core’s internals — is still built on the classic compiler-generated state machines. Your code can opt into Runtime-Async; the libraries it calls into can’t yet. Early independent benchmarking on nested async chains under real request load found gains at low concurrency essentially wash out, and one high-pressure test (50 requests × 10 parallel) showed a double-digit percent regression compared to the classic state machine, because Runtime-Async currently pays setup overhead even on the fast path where a task is already complete — a case the classic compiler pattern optimizes aggressively for. Microsoft’s own framing is consistent with this: the real ecosystem-wide gains show up once the framework itself is recompiled against Runtime-Async, which is targeted for .NET 11, not .NET 10.
So for anything shipping now — the WinForms app, the inspection API, the triathlon platform’s Azure Functions — I’m leaving this off. Not because it’s a bad idea, but because “experimental preview, framework not yet recompiled, one documented high-load regression” is not a combination I want to debug in a client’s production environment when the classic state machine already does the job correctly. I’ll revisit when .NET 11 ships in November and the framework’s had a chance to catch up to its own runtime.
Before you go
- Please take a moment to like the post and follow the writer!
- Did you know that over 400,000 developers share what they’re building, learning, and discovering across our platforms every month? Learn how you can contribute here
메타데이터
- post_id
- 38db930d881b
- slug
- your-await-isnt-broken-your-mental-model-of-it-is-38db930d881b
- url
- https://javascript.plainenglish.io/your-await-isnt-broken-your-mental-model-of-it-is-38db930d881b
- canonical_url
- https://javascript.plainenglish.io/your-await-isnt-broken-your-mental-model-of-it-is-38db930d881b
- author_url
- https://medium.com/@riturajpokhriyal
- status
- ok
- fetched_at
- 2026-07-10 06:45:42