Controlling Which Thread Runs Your Asynchronous Code
Most of the time it does not matter which thread runs a piece of code. A calculation produces the same number on any core, and asking which thread performed it is an idle question. A handful of operations, though, care very much. In WinForms and WPF, every UI element may only be touched by the thread that created it, which in practice is one single thread per application, the UI thread; touching a label from anywhere else throws. In ASP.NET classic, the one that ships with .NET Framework 4.8 and earlier, HttpContext.Current only returns the right value on the thread that is processing the request. COM components have their own elaborate threading rules, and calling one from the wrong apartment either fails or quietly becomes very slow. And blocking is thread-specific by nature: blocking the UI thread freezes the application, while blocking enough thread pool threads stops a server from accepting connections. Any third-party library can add rules of its own; modern .NET code tends not to care, but older and native code often does. So the question of which thread runs the code after an await turns out to matter, and it has a precise answer.
The short version of that answer is easy to remember. In a UI application, an await used on the UI thread, without ConfigureAwait(false), resumes on that same UI thread. In ASP.NET classic, an await used on a request-processing thread, without ConfigureAwait(false), resumes on that same thread. In every other case - console applications, ASP.NET Core, background threads, thread pool work - the code after the await resumes on the thread pool. That short list also gives away the motivation for the whole feature: it exists so that UI code and old ASP.NET code can be written as if it were sequential. One important qualification: everything described here is a property of await specifically, not of asynchronous code in general. ContinueWith does none of it and always schedules its callback on the thread pool, which is precisely why hand-written ContinueWith code in a UI application has to bounce back to the UI thread through Control.BeginInvoke or Dispatcher.BeginInvoke, and why replacing an await with a ContinueWith is not the innocent refactoring it appears to be.
None of this is a special case wired into the compiler for Microsoft's own UI frameworks. It is built on a public .NET abstraction called SynchronizationContext, whose entire job is to represent the idea of "run this piece of code over there" without naming any particular framework. It offers two ways to do that: Send, which waits until the target thread has run the code, and Post, which hands the work over and returns immediately. await only ever uses Post. A context becomes active by being attached to a thread with SynchronizationContext.SetSynchronizationContext, after which any code on that thread can find it through SynchronizationContext.Current. WinForms, WPF and ASP.NET classic each install their own derived class on their special threads, and await, which knows nothing about any of them, simply captures Current before suspending and posts the continuation back to it afterwards. Where nobody has installed one - console applications, ASP.NET Core, and every ordinary background thread - Current is null, and the continuation goes to the thread pool.
Because a console application has no such context, the core example builds one. The nested WorkerLoopContext class below is a complete, working SynchronizationContext that owns a single dedicated thread and runs everything posted to it there, which is exactly the shape of a UI thread, minus the windows. With it in place, the same three lines of async code can be run inside a context and outside one and the difference watched directly. Here is the complete core concept example as it appears in the class file:
It leans on three tiny helpers. WhereAmI names the current thread rather than printing its numeric id, because thread ids change from run to run and would make the output impossible to compare against; it reports the thread's Name when it has one and the words "thread pool" when IsThreadPoolThread is true, which is enough to tell every case in this example apart. Describe reports the type name of the context installed on the current thread, or the word "none" when SynchronizationContext.Current is null. AlreadyFinishedAsync is an async method that awaits something already finished and so never suspends at all, which is what Part 4 needs in order to make its point:
The context itself is the heart of the example, and it is smaller than its reputation suggests. It keeps a BlockingCollection of queued callbacks - the file opens with a using directive for System.Collections.Concurrent to get it - and runs a loop on one dedicated thread that takes the next callback and invokes it, blocking when the queue is empty. The first thing that loop does is call SetSynchronizationContext, which is what makes every await on that thread capture this object. Post simply adds to the queue, and it is the only member await needs, which is why Send, CreateCopy and the rest are left alone. The thread is created as a background thread so that it can never keep the process alive, and Dispose ends the loop by calling CompleteAdding. The one piece that needs explaining is RunAsync, which posts a piece of work onto the loop thread and hands the caller a Task that completes when that work does. It cannot simply post and forget, because the caller has to know when the work finished, so it uses a TaskCompletionSource to capture the Task the work returns and Unwrap to turn that task-of-a-task into a single Task that faults when the inner one faults. The try/catch around it exists so that work which throws before returning a Task faults the caller's Task instead of killing the loop thread:
Part 1 establishes the baseline. On the main thread of a console application SynchronizationContext.Current prints "none", and awaiting a Task.Delay of fifty milliseconds - long enough to guarantee a real suspension, short enough not to slow the demo down - moves execution to the thread pool. Part 2 runs the identical code inside WorkerLoopContext. Now Current prints the context's type name, and the lines before and after the await both report the worker loop thread: the await captured the context, posted its continuation to the queue, and the loop thread picked it up. That is, in miniature, the entire mechanism behind a WinForms event handler that can safely assign to a label after an await. Part 3 changes exactly one thing, adding ConfigureAwait(false) to the await, and the continuation lands on the thread pool instead. That is all ConfigureAwait(false) does: it tells await to ignore the captured context.
It is worth stating plainly, because the popular summary of ConfigureAwait is wrong in both directions. The popular summary says that without it you stay on the same thread and with it you move to another one. Part 1 refutes the first half: no ConfigureAwait(false) anywhere, and the thread changed anyway, because there was no context to return to. Part 4 refutes the second half. It awaits AlreadyFinishedAsync with ConfigureAwait(false) applied, and execution stays exactly where it was, on the worker loop thread, because an await on an already completed task never suspends at all and therefore has no continuation to schedule anywhere. This is not a contrived case; methods that return an already completed Task are common, either because a library author wanted room to become asynchronous later without changing the signature, or because a cached value made the work unnecessary this time. It is also the reason the guidance says that when you genuinely want to get off the current context, the tool is Task.Run, not ConfigureAwait(false), which may well do nothing at all.
Part 4 then introduces Task.Yield, which is the one thing that always suspends. Awaiting it schedules the rest of the method through the current context and returns immediately, so anything already waiting in the queue gets its turn first - the example proves this by posting a line of work to the loop just before yielding, and that line appears in the output before the code after the yield. In a UI application this is the async-friendly equivalent of the old Application.DoEvents trick: a long loop that yields on every iteration lets pending input and repaint events be handled, so the window stays responsive instead of freezing. Notice also which thread the code lands on after the yield - the same worker loop thread, because Task.Yield follows exactly the same rules as every other await. Running the core example prints:
The real-world example is the failure this whole mechanism is famous for. A click handler blocks on an asynchronous call instead of awaiting it, and the application hangs forever. The scenario builds a context named "UI thread", runs the same handler on it three times, and lets the results speak:
Two handlers and a stand-in library do the work. RunBlockingClickHandler is the buggy one: it starts the call and then blocks on the resulting Task instead of awaiting it. Its label parameter is only there to name the line it prints, and it waits with a five hundred millisecond timeout rather than blocking forever, because a real deadlock would hang the demo; the fetch it is waiting for needs a hundred milliseconds, so five hundred is five times more than enough and a timeout can only mean the work is never going to finish. RunAwaitingClickHandlerAsync is the same handler written properly, with an await instead of a block. FetchReportAsync stands in for the library call, and its breakOutOfContext parameter selects between the two lines the whole scenario is about: an ordinary await that sends its continuation back to the captured context, and the same await with ConfigureAwait(false), which does not:
The first run deadlocks, and the mechanism is worth following step by step. The handler runs on the UI thread and calls FetchReportAsync, which awaits a delay and captures the UI context on the way out. The handler then blocks that same UI thread waiting for the Task. A hundred milliseconds later the delay finishes and the continuation is posted to the UI queue - the queue that only the UI thread drains, and the UI thread is busy blocking. The continuation cannot run, so the Task cannot complete, so the block cannot end. The UI thread is waiting for work that only the UI thread can perform. The second run changes nothing except adding ConfigureAwait(false) inside the library, and that is enough: the continuation goes to the thread pool, the Task completes without needing the UI thread, and the blocked handler wakes up with its result. The third run leaves the library exactly as it was in the first, deadlocking case and fixes the handler instead, replacing the block with an await - and it not only completes, it completes back on the UI thread, as the output shows, which is what makes it safe to touch UI elements on the next line. Running the scenario prints:
That third result is the reason the popular advice to put ConfigureAwait(false) on every await is bad advice for application code. It does prevent this deadlock, but it prevents it by throwing away the feature that makes await pleasant to use: an application-level handler with ConfigureAwait(false) on it resumes on the thread pool, so the very next line that touches a label throws an InvalidOperationException, and the only way to fix that is to marshal back manually with Control.BeginInvoke - which is precisely the code await was invented to spare you. Microsoft's own guidance is narrower than the folklore: use ConfigureAwait(false) in library code, where you genuinely do not care which context you resume on, and leave it out of application code, where you usually do. Leave it out too when your code must stay on its thread for other reasons, such as thread-local storage or thread settings, and leave it out in environments that have no SynchronizationContext at all - console applications and ASP.NET Core - where it can only ever be noise. Note that the library in the second and third runs above is the same method in both cases: adding ConfigureAwait(false) inside a library is useful exactly because the library does not know whether its caller will await politely or block rudely. But even in a library the context can matter, most obviously in one written for a UI framework, and less obviously in one that invokes application callbacks - the library may not care which thread it is on, but the application's callback might.
There is also an older, framework-level answer to this deadlock, which predates async/await and indeed .NET itself: while waiting, let the UI keep handling its queue. In WinForms that is Application.DoEvents, called in a loop until the Task reports completion, and it works because the input queue - where mouse and keyboard events, Control.BeginInvoke calls and SynchronizationContext posts all end up together - keeps being drained. It resolves the deadlock and even keeps the window responsive, at the cost of spinning a CPU core flat out while it waits, so it is a last resort rather than a design, and Task.Yield is its modern equivalent for code that is already asynchronous.
Two details complete the picture. The first is TaskScheduler, a second mechanism that sits underneath await alongside SynchronizationContext and does much the same job. TaskScheduler.Default queues work to the thread pool; TaskScheduler.FromCurrentSynchronizationContext builds one that posts through the current context instead, and throws if there is no current context to wrap; and TaskScheduler.Current reports the one in effect. Unlike SynchronizationContext it cannot be installed on a thread, but it can be passed explicitly to Task.Run, ContinueWith and friends, which is how a hand-written continuation is sent back to a UI thread. ConfigureAwait(false) makes await ignore this too. The second detail is the ConfigureAwaitOptions overload added in .NET 8, which is best recognised rather than used: None confusingly means the same as ConfigureAwait(false) rather than meaning nothing at all; ContinueOnCapturedContext restores the default behaviour and exists only because the values are flags; ForceYielding makes an await suspend even on a completed task, which is Task.Yield with extra steps; and SuppressThrowing does not suppress all throwing, only failures that happen after the first await inside the called method, and throws at run time if used on a Task of T. None of the four is available on ValueTask, so the plain boolean overload remains the better choice.
Putting it all together, the real rule for where the code after an await runs has four steps, applied in order. If the awaited task is already complete, the code simply continues on the current thread and ConfigureAwait(false) changes nothing. Otherwise, if the current thread has a SynchronizationContext and ConfigureAwait(false) was not used, the continuation is posted through that context. Otherwise, if the current task has a TaskScheduler associated with it and ConfigureAwait(false) was not used, that scheduler runs it. Otherwise - which covers both ConfigureAwait(false) and the ordinary case of a thread with neither a context nor a scheduler - the default scheduler runs it on the thread pool. Unless you are using a framework that installs one of these, or writing one yourself as this example does, that long rule and the short three-line version at the top of this article always agree. Both examples are run one after the other from the program entry point: