SynchronizationContext is one of the most misunderstood types in .NET. It sits at the heart of async/await's continuation scheduling, yet most developers never interact with it directly. Understanding it explains why async code behaves differently in WPF, legacy ASP.NET, and ASP.NET Core.

What Is a SynchronizationContext?

At its simplest, SynchronizationContext is an abstraction for "post work to a specific execution environment." It has two key methods:

Example.cs
public class SynchronizationContext
{
    // Queue work and return immediately
    public virtual void Post(SendOrPostCallback d, object? state);

    // Queue work and block until complete
    public virtual void Send(SendOrPostCallback d, object? state);
}

The base class implementation of Post simply queues the callback to the thread pool. But subclasses override this to marshal work to a specific thread or context.

Platform-Specific Contexts

Each .NET UI framework provides its own SynchronizationContext:

WPF installs a DispatcherSynchronizationContext. Its Post method calls Dispatcher.BeginInvoke, marshalling work back to the UI thread. This is why awaiting a task in a WPF event handler automatically resumes on the UI thread — the captured context posts the continuation to the dispatcher queue.

WinForms installs a WindowsFormsSynchronizationContext. It calls Control.BeginInvoke internally, achieving the same UI-thread marshalling.

Legacy ASP.NET (pre-Core) installed an AspNetSynchronizationContext that serialised request processing. Only one thread could execute within a given request context at a time. This was the source of countless deadlocks when developers blocked on async code with .Result.

ASP.NET Core installs no SynchronizationContext at all. SynchronizationContext.Current is null. This was a deliberate design decision — it eliminates the serialisation bottleneck and makes ConfigureAwait(false) unnecessary in application code.

How Await Uses It

When the compiler generates the state machine for an async method, each await point checks SynchronizationContext.Current. If one exists, the continuation is posted through it. Here is the simplified flow:

Example.cs
// Pseudocode of what the state machine does at an await point
var context = SynchronizationContext.Current;
if (context != null)
{
    // Post continuation back to the captured context
    context.Post(_ => stateMachine.MoveNext(), null);
}
else
{
    // Fall back to the current TaskScheduler (usually thread pool)
    TaskScheduler.Current.QueueTask(continuation);
}

This is why async methods "just work" on UI threads — the context ensures the continuation runs where it needs to. It is also why ConfigureAwait(false) exists: it tells the awaiter to skip context capture and always resume on the thread pool.

Building a Custom SynchronizationContext

Sometimes you need a custom context — for testing or for single-threaded execution environments. Here is a minimal example that processes work on a dedicated thread:

SingleThreadSynchronizationContext.cs
public class SingleThreadSynchronizationContext : SynchronizationContext
{
    private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
    private readonly Thread _thread;

    public SingleThreadSynchronizationContext()
    {
        _thread = new Thread(Run) { IsBackground = true };
        _thread.Start();
    }

    public override void Post(SendOrPostCallback d, object? state)
    {
        _queue.Add((d, state));
    }

    public override void Send(SendOrPostCallback d, object? state)
    {
        using var done = new ManualResetEventSlim();
        _queue.Add((_ =>
        {
            d(state);
            done.Set();
        }, null));
        done.Wait();
    }

    private void Run()
    {
        SetSynchronizationContext(this);
        foreach (var (callback, state) in _queue.GetConsumingEnumerable())
        {
            callback(state);
        }
    }

    public void Complete() => _queue.CompleteAdding();
}

This pattern is useful in unit tests where you want to verify that async continuations execute in order on a single thread, mimicking a UI context.

Checking the Current Context

You can inspect the current context at any point:

Example.cs
var ctx = SynchronizationContext.Current;
Console.WriteLine(ctx?.GetType().Name ?? "null (thread pool)");

In a WPF handler, this prints DispatcherSynchronizationContext. In ASP.NET Core middleware, it prints null. Knowing this helps diagnose unexpected async behaviour.

Practical Implications

The absence of a SynchronizationContext in ASP.NET Core is the single biggest reason async deadlocks are rare in modern web applications. If you are migrating code from legacy ASP.NET to ASP.NET Core, you can safely remove most ConfigureAwait(false) calls in application-level code — though keeping them in shared libraries remains best practice.

For library authors, never assume a particular context exists. Always use ConfigureAwait(false) to avoid coupling your library to the caller's execution environment. And if you are building infrastructure that needs a custom scheduling model — test harnesses, game loops, or embedded runtimes — implementing your own SynchronizationContext gives you full control over where async continuations run.