Threading, the Dispatcher & Async

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A desktop application is a program that must answer a person at any moment. Every click, key press and repaint is handled by one thread, and as long as that thread is busy nothing on screen moves, not even the window’s close button. Threading in WPF is therefore not an advanced topic: it decides whether loading the book list freezes the window for two seconds or shows a progress bar the user can cancel. This page explains the rule that governs it (a WPF element may be touched only by the thread that created it), the Dispatcher that lets other threads ask the UI thread for work, and the modern way to write all of this, async and await, with its progress and cancellation conventions. It finishes with the traps: collections changed from the wrong thread, deadlocks, and async void.

The page was written against these versions, current in October 2026:

  • .NET 10 LTS, latest patch 10.0.13, with C# 14. .NET 11 is at release candidate 2 (general availability is expected 10 to 13 November 2026). The WPF threading API (Dispatcher, DispatcherPriority, DispatcherTimer, BindingOperations.EnableCollectionSynchronization) is unchanged between .NET Framework 4.8 and .NET 8, 9 and 10, and the .NET 11 WPF notes list no new APIs in this area. Dispatcher.InvokeAsync and BindingOperations.EnableCollectionSynchronization exist since .NET Framework 4.5.

  • CommunityToolkit.Mvvm 8.4.2 for ObservableObject, [ObservableProperty] on partial properties and [RelayCommand], used in the examples.

  • Windows App SDK 2.5.1 (stable channel) for the WinUI 3 DispatcherQueue comparison.

  • Visual Studio 2026 18.11.1.

The running scenario is Bookshelf Desktop, with a BooksViewModel, a BookViewModel, an IBookRepository and an IDialogService. The commands page shows how LoadCommand is declared and bound; this page shows what happens inside it when the repository is slow. The examples add two members to the abridged IBookRepository of that page, because loading with progress needs them (the interface is shown where it is used).

What Microsoft Learn Provides, and What It Does Not

The official documentation covers this area in four places:

  • The Threading model article of the WPF guide explains the dispatcher, the thread affinity rule, Invoke versus BeginInvoke, the priority levels, multiple windows on multiple threads, Task.Run, nested pumping and reentrancy. Its older examples use BeginInvoke with delegates; the newer part (blocking operations) uses Task.Run and await.

  • The .NET API reference documents Dispatcher, DispatcherPriority, DispatcherOperation, DispatcherTimer, DispatcherSynchronizationContext and BindingOperations.EnableCollectionSynchronization. The last one has the most complete prose of the group: its remarks list the application’s responsibilities and what WPF guarantees in return.

  • The .NET documentation on the task-based asynchronous pattern, IProgress<T> and cancellation describes the conventions this page applies to WPF.

  • The Task.ConfigureAwait reference links to the .NET team’s ConfigureAwait FAQ.

What it does not provide: there is no WPF article that shows an MVVM view model loading data with progress and cancellation, no WPF-specific guidance on when to use ConfigureAwait(false), and no description of how DispatcherSynchronizationContext maps await continuations to dispatcher priorities (the class page has a single remark and a list of members). The view model, the repository and the guidance in the sections on async and on libraries are patterns built on the documented APIs, not official samples, and are labelled as such.

The Rule: One Thread Owns Each Element

Most WPF classes derive from DispatcherObject (the page on the WPF architecture shows the hierarchy: DispatcherObject, DependencyObject, Visual, UIElement, FrameworkElement, Control). At construction a DispatcherObject stores a reference to the Dispatcher of the thread that created it, and from then on only that thread may read or write its properties and call its methods. The Learn article explains the reason: a list box would look strange if a background thread changed its contents while it was being painted, so WPF enforces mutual exclusion instead of locking every property.

A typical WPF application starts with two threads. A UI thread receives input, handles events, runs layout and application code and builds the visual tree. A separate render thread composes the scene in the background and is not something application code touches. Application code almost always means the UI thread, which is the thread that called Application.Run (in a project generated by the SDK, the thread of the generated Main).

Member Behaviour

DispatcherObject.Dispatcher

The Dispatcher the object belongs to. It can be read from any thread, which is what makes element.Dispatcher.InvokeAsync(...) possible from a worker thread.

DispatcherObject.CheckAccess()

Returns true if the calling thread owns the object. It is hidden from IntelliSense (EditorBrowsable.Never) but supported, and it is the cheap check.

DispatcherObject.VerifyAccess()

Throws InvalidOperationException if the calling thread does not own the object. Every dependency-property setter on a WPF element calls it, which is why a cross-thread write fails immediately, at the line that wrote the property, and not later.

Dispatcher.CheckAccess() and Dispatcher.VerifyAccess()

The same two checks on the dispatcher itself, used when you hold a Dispatcher and no element.

Dispatcher.Thread

The thread the dispatcher belongs to.

Dispatcher.CurrentDispatcher

The dispatcher of the calling thread. Reading it on a thread that has none creates one, which is a classic trap (see the pitfalls): from a worker thread it returns a new, idle dispatcher, not the UI one.

Application.Current.Dispatcher

The dispatcher of the thread that created the Application object, normally the UI thread. This is the safe way to reach the UI dispatcher from anywhere, as long as Application.Current is not null (it is null in unit tests and during shutdown).

Two groups of types are exempt. All members of Dispatcher itself except DisableProcessing are free-threaded, and a Freezable (a brush, a geometry, a transform) is free-threaded once it is frozen. Freezing a brush or an image on a worker thread and handing it to the UI thread is the documented way to prepare graphics off the UI thread.

The code below shows the rule being broken and the three ways of repairing it. The BookCoverLoader is a code-behind helper that updates a TextBlock with a status text:

using System.Windows;
using System.Windows.Controls;

namespace Bookshelf.Desktop.Threading;

public sealed class StatusWriter(TextBlock target)
{
    // Throws InvalidOperationException when called from a worker thread.
    public void WriteUnsafe(string text) => target.Text = text;

    // Repair 1: check, then marshal only when needed.
    public void Write(string text)
    {
        if (target.Dispatcher.CheckAccess())
        {
            target.Text = text;
        }
        else
        {
            // Post and continue; the worker is not blocked.
            _ = target.Dispatcher.InvokeAsync(() => target.Text = text);
        }
    }

    // Repair 2: assert in debug builds that the caller is on the UI thread.
    public void WriteOnUiThread(string text)
    {
        target.Dispatcher.VerifyAccess();
        target.Text = text;
    }

    // Repair 3: do not touch the element from the worker at all; return the data
    // and let the awaiting code, which resumes on the UI thread, assign it.
    public async Task WriteAfterWorkAsync(Func<string> work)
    {
        var text = await Task.Run(work);
        target.Text = text;
    }
}

Source: Threading model

The message of the exception for a cross-thread access is, in English runtimes, "The calling thread cannot access this object because a different thread owns it." The third repair is the one to prefer in new code and is the subject of the section on async and await; the first two are what code that receives callbacks from other threads (events from a serial port, a file watcher, a network library) still needs.

The Dispatcher

The Dispatcher is the object that gives a thread its work. It maintains a prioritized queue of work items for one thread. The thread runs a loop that takes the highest-priority item, runs it to completion, then takes the next one. Input handling, layout, rendering, data-binding updates, timers, and the delegates you post with Invoke are all items in this queue. Three consequences follow from "to completion":

  • While a handler runs, nothing else on that thread runs. A handler that takes two seconds freezes the window for two seconds. Input does not interrupt a running item.

  • Work items should be small. The Learn article states the goal as maximizing dispatcher throughput so that items do not go stale waiting in the queue.

  • Anything that must happen "later, but on the UI thread" is expressed as a new item in the same queue.

A Dispatcher is tied to one thread for life. Once a dispatcher exists for a thread, it is the only one that thread can have, even after it is shut down, and a shut-down dispatcher cannot be restarted. A dispatcher on a background thread must be shut down before the thread exits. The members worth knowing:

Member Purpose

Invoke, BeginInvoke, InvokeAsync

Post a delegate to the queue. Covered in the next sections.

Run()

Pushes the main execution frame, that is, starts the message loop. Application.Run calls it for the UI thread. You call it yourself only for an extra UI thread.

PushFrame(DispatcherFrame)

Enters a nested loop that keeps processing the queue until the frame’s Continue is set to false. MessageBox.Show, Window.ShowDialog and other modal calls use it.

InvokeShutdown() and BeginInvokeShutdown(DispatcherPriority)

Stop the dispatcher, synchronously or asynchronously.

ShutdownStarted, ShutdownFinished, HasShutdownStarted, HasShutdownFinished

Observe shutdown.

UnhandledException and UnhandledExceptionFilter

Raised when a delegate run through Invoke or BeginInvoke throws and nothing catches it. For the UI dispatcher, Application.DispatcherUnhandledException is the usual hook.

Yield() and Yield(DispatcherPriority)

An awaitable that returns control to the dispatcher so that it can process other items, and resumes the awaiting code afterwards, at the given priority.

DisableProcessing()

Disables processing of the queue until the returned object is disposed. WPF uses it internally during sensitive operations; it is the only non-free-threaded member.

Hooks

Diagnostic events (operation posted, started, completed, aborted, priority changed).

Priorities

Every queued item has a DispatcherPriority. Higher priorities run first; items of equal priority run in order of arrival. The enumeration, with the values and descriptions from the API reference:

Priority Value Meaning

Invalid

-1

Not a usable priority; passing it throws.

Inactive

0

Operations are not processed. An item queued at Inactive is parked until its priority is changed.

SystemIdle

1

Processed when the system is idle.

ApplicationIdle

2

Processed when the application is idle.

ContextIdle

3

Processed after background operations have completed.

Background

4

Processed after all other non-idle operations are completed.

Input

5

Same priority as input.

Loaded

6

After layout and render have finished but just before items at Input priority are serviced. Used when raising the Loaded event.

Render

7

Same priority as rendering.

DataBind

8

Same priority as data binding.

Normal

9

The typical application priority, and the default for InvokeAsync and BeginInvoke overloads without a priority.

Send

10

Processed before other asynchronous operations; the highest priority. An operation posted with Invoke on its own dispatcher at Send bypasses the queue and runs immediately.

Three remarks from the reference matter in practice. First, there is no system state or "idleness" behind ApplicationIdle and ContextIdle in the current version of WPF; both are valid, and ContextIdle is simply higher than SystemIdle. Second, the dispatcher does not throttle an operation that uses a lot of CPU: if a Background item runs for a second, the UI is blocked for a second, and one way to throttle is to use a timer. Third, items are ordered by priority only among what is queued; a long item at any priority blocks everything behind it.

The practical guidance:

  • Leave Normal for results the user is waiting for, such as showing the loaded books.

  • Use Background for work that can wait until input and rendering have been served, such as building a secondary view or updating a non-visible count. With Background, a stream of updates cannot starve input.

  • Do not use Send, Inactive or the idle priorities without a reason you can name. A flood of Send items starves rendering and input.

  • Input priority exists so that code can place work behind pending user input; Render and DataBind are the levels WPF itself uses to run layout and binding updates, and posting at them is rarely needed.

Nested pumping and reentrancy

Some calls cannot return until the user acts, yet the thread must stay responsive. MessageBox.Show is the Learn article’s example: it returns only when the user clicks a button, but its window needs a message loop, and the disabled window behind it still has to repaint. WPF solves it with PushFrame, which stores the execution point of the call and starts a nested message loop on the same thread. When the dialog closes the nested loop ends and Show returns.

The consequence is reentrancy: while a handler is blocked inside a nested loop, other queued items, including other event handlers of the same window, run on top of it. A handler that opens a dialog can find, when the dialog returns, that the state it read before the call changed. Dispatcher.DisableProcessing is how WPF protects some of its own critical sections from this. The practical rules for application code are to re-read state after any call that shows a modal window, and not to call PushFrame yourself (the DoEvents pattern built on it is a well-known source of bugs).

Invoke, BeginInvoke and InvokeAsync

Three families of methods post a delegate:

Method Blocks the caller? Returns Notes

Invoke

Yes, until the UI thread has run the delegate

Nothing, or the delegate’s result with the Invoke<TResult>(Func<TResult>) overloads

Synchronous. Overloads with a TimeSpan give up waiting after the timeout; overloads with a CancellationToken can be cancelled. Exceptions from the delegate propagate to the caller of Invoke.

BeginInvoke

No

DispatcherOperation

The original asynchronous API (since WPF 3.0). Takes a delegate and an object[] of arguments, so the compiler cannot check the delegate type: a mismatch is a runtime error. The overload set is large and confusing.

InvokeAsync

No

DispatcherOperation or DispatcherOperation<TResult>, which can be awaited

Added in .NET Framework 4.5. Takes an Action or a Func<TResult>, so lambdas work and arguments are captured by the closure. Overloads with a DispatcherPriority and a CancellationToken. The default priority is Normal.

InvokeAsync is the one to use for new code. The Learn article lists it first among the three and its remarks promise the contract that matters: it stores in the returned task all non-usage exceptions that Invoke would have thrown, and they are rethrown when the task is awaited, while usage exceptions such as ArgumentException are thrown synchronously. A DispatcherOperation is not an exception-handling boundary of its own: if nobody awaits it and the delegate throws, the exception is handled by the dispatcher, which raises its unhandled-exception events, and is not preserved by the operation.

The DispatcherOperation object returned by BeginInvoke and InvokeAsync is a handle on the queued item:

Member Purpose

Status

Pending, Aborted, Completed or Executing. An operation posted to a dispatcher that has shut down is Aborted.

Priority

Readable and settable. Changing it moves the item in the queue.

Abort()

Removes a pending item. It returns false if the item already started or finished.

Wait() and Wait(TimeSpan)

Block until the item completes. Waiting on the UI thread for an item posted to the UI thread is a self-inflicted deadlock; the section on deadlocks returns to it.

Task

A Task that completes when the operation does. await operation also works, through GetAwaiter().

Result

The return value of the delegate, after completion.

Completed and Aborted

Events.

The helper below is used by code that gets callbacks from non-WPF threads. It posts at a chosen priority, supports cancellation, and returns the awaitable operation:

using System.Windows;
using System.Windows.Threading;

namespace Bookshelf.Desktop.Threading;

public static class UiThread
{
    private static Dispatcher Dispatcher =>
        Application.Current?.Dispatcher
        ?? throw new InvalidOperationException("The WPF application is not running.");

    /// <summary>Runs the action on the UI thread. Runs it inline when already there.</summary>
    public static Task RunAsync(
        Action action,
        DispatcherPriority priority = DispatcherPriority.Normal,
        CancellationToken cancellationToken = default)
    {
        var dispatcher = Dispatcher;
        if (dispatcher.CheckAccess())
        {
            action();
            return Task.CompletedTask;
        }

        // DispatcherOperation is awaitable; Task exposes its completion as a Task.
        return dispatcher.InvokeAsync(action, priority, cancellationToken).Task;
    }

    /// <summary>Runs the function on the UI thread and returns its result.</summary>
    public static async Task<T> RunAsync<T>(
        Func<T> function,
        DispatcherPriority priority = DispatcherPriority.Normal)
    {
        var dispatcher = Dispatcher;
        return dispatcher.CheckAccess()
            ? function()
            : await dispatcher.InvokeAsync(function, priority);
    }
}

A call such as await UiThread.RunAsync(() => Books.Add(vm), DispatcherPriority.Background) inside a file-watcher callback adds the book to the observable collection on the UI thread at a priority that cannot starve input.

When to choose which method:

  • From a worker thread, when the UI update is fire-and-forget: InvokeAsync.

  • From a worker thread, when the worker needs the UI value or must not continue until the UI is updated (reading the text a user typed, asking for the window size): InvokeAsync with await where the caller is async, Invoke only when it is not. Invoke also holds a worker thread for as long as the UI thread is busy.

  • From code that is already on the UI thread and needs to run after the current handler finishes, for example to set focus after a layout pass: InvokeAsync with DispatcherPriority.Loaded or Background. It defers the delegate behind work already queued.

  • From modern async code that started on the UI thread: you rarely need the dispatcher at all, as the section on async and await shows.

DispatcherTimer

DispatcherTimer is a timer integrated into the dispatcher queue. When the interval elapses it posts a Tick item at the timer’s priority, and the Tick handler runs on the dispatcher’s thread, so the handler may touch elements directly. Its members are Interval, IsEnabled, Start(), Stop(), Tick and Tag. The constructors accept a DispatcherPriority (default Background) and the Dispatcher to use; the full constructor also takes the interval and the handler.

The remarks in the reference define the contract exactly: the timer is re-evaluated at the top of every dispatcher loop; it is guaranteed not to fire before the interval, but not guaranteed to fire at the interval, because the tick competes with every other queued item. A timer whose handler takes longer than the interval does not overlap with itself; it simply runs late. It is not a precision timer, and not a place for slow work.

DispatcherTimer System.Timers.Timer / System.Threading.Timer

Handler runs on

The UI thread (the dispatcher’s thread)

A thread-pool thread

Touching UI elements in the handler

Allowed

Not allowed without Dispatcher.InvokeAsync

Priority

Configurable

Not applicable

Accuracy

Delayed by whatever the UI thread is doing

Better, independent of the UI thread

Suitable for

Clocks, status refresh, debouncing search text, blink animations

Background polling, periodic work that does not touch the UI

The reference adds one lifetime warning: a DispatcherTimer keeps an object alive whenever the object’s methods are bound to the timer. A view model that subscribes to Tick and never stops the timer is never collected. Stop the timer and unsubscribe when the owner is closed.

The view model below uses a timer to debounce the search box of Bookshelf Desktop: each keystroke restarts a 300 ms timer, and the filter runs only when the user pauses:

using System.ComponentModel;
using System.Windows.Data;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;

namespace Bookshelf.Desktop.ViewModels;

public partial class BookSearchViewModel : ObservableObject, IDisposable
{
    private readonly ICollectionView _view;
    private readonly DispatcherTimer _debounce;

    public BookSearchViewModel(ICollectionView view)
    {
        _view = view;
        _debounce = new DispatcherTimer(DispatcherPriority.Input)
        {
            Interval = TimeSpan.FromMilliseconds(300),
        };
        _debounce.Tick += OnDebounceTick;
    }

    [ObservableProperty]
    public partial string? SearchText { get; set; }

    partial void OnSearchTextChanged(string? value)
    {
        // Restart the interval: Stop then Start resets the countdown.
        _debounce.Stop();
        _debounce.Start();
    }

    private void OnDebounceTick(object? sender, EventArgs e)
    {
        _debounce.Stop();
        _view.Filter = string.IsNullOrWhiteSpace(SearchText)
            ? null
            : item => item is BookViewModel book
                && book.Title.Contains(SearchText, StringComparison.CurrentCultureIgnoreCase);
    }

    public void Dispose()
    {
        _debounce.Stop();
        _debounce.Tick -= OnDebounceTick;
    }
}

Source: DispatcherTimer

In the XAML the text box uses UpdateSourceTrigger=PropertyChanged so that each keystroke reaches SearchText; the data binding page explains that setting. The debounce lives in the view model because a timer is an application behaviour; the binding itself would otherwise also support a delay (Binding.Delay), a cheaper option for a single text box that needs no other logic.

async and await on the UI Thread

async and await solve the problem the dispatcher poses without making the programmer write dispatcher calls. When an async method awaits a task that is not yet complete, it returns to its caller, here the UI thread’s message loop, which keeps serving the queue. When the task completes, the rest of the method (the continuation) is scheduled, and by default it is scheduled on the context that was current when the await started.

That context is a SynchronizationContext. WPF installs a DispatcherSynchronizationContext on the UI thread. Its Post method queues the continuation on the thread’s Dispatcher asynchronously, and its Send method runs it synchronously. By default it is associated with the current dispatcher, and constructors also accept a specific Dispatcher and a DispatcherPriority. The result:

  • An await in a UI event handler, a command or a view-model method resumes on the UI thread. After var books = await repository.GetAllAsync(ct); the next line can add to an ObservableCollection bound to a list, with no dispatcher call.

  • An await on a worker thread (inside Task.Run, or after ConfigureAwait(false)) has no context to return to and resumes on a thread-pool thread, where touching an element throws.

  • The code before the first await runs synchronously on the caller’s thread, so it runs on the UI thread when an event handler or command called it, and is as capable of freezing the window as any other UI-thread code.

The Learn threading article demonstrates exactly this with Task.Run: the handler updates the UI to show the fetch is in progress, starts a task, is suspended while the task runs without blocking the UI thread, and continues when the task finishes, because "the synchronization context of WPF handles suspending the code".

Where the work is CPU-bound (parsing a large file, sorting, image decoding), Task.Run moves it to the thread pool. Where the work is I/O-bound (a database query, an HTTP call, a file read through an async API), do not wrap it in Task.Run: the I/O is already asynchronous, wrapping it only burns a thread-pool thread waiting. A rule of thumb: await the asynchronous API directly; use Task.Run only for code that would otherwise execute for a perceptible time on the UI thread.

Situation Where the continuation runs What to do

await in a handler or view-model method called from the UI thread

UI thread

Use the result directly, including changing bound properties and collections.

await Task.Run(() => Compute())

Compute on a thread-pool thread; the continuation on the UI thread

Do not touch elements or bound collections inside the lambda; return a value.

await x.ConfigureAwait(false) in a method called from the UI thread

A thread-pool thread

Fine in a library or repository; wrong in a method that updates the UI afterwards.

await in a method called from a worker thread

That worker’s context, usually the thread pool

Marshal UI changes with Dispatcher.InvokeAsync or IProgress<T>.

await Dispatcher.Yield()

UI thread, after the queue has served other items

Lets a long UI-thread loop keep input responsive; prefer moving the loop off the UI thread.

Loading books with progress and cancellation

The scenario combines everything so far. The user opens Bookshelf Desktop; the catalog has thousands of books and the query is slow. The view model starts the load, shows a progress bar, offers a Cancel button, and appends the results to the list. This is how the pieces interact:

sequenceDiagram actor User participant UI as UI thread (Dispatcher) participant VM as BooksViewModel participant Pool as Thread pool participant Repo as IBookRepository User->>UI: Click Load UI->>VM: LoadCommand executes VM->>VM: Create Progress, captures the UI context VM->>Repo: GetAllAsync with progress and token Note over UI: The message loop keeps running, the window stays responsive Repo->>Pool: Query continues, ConfigureAwait(false) Pool-->>Repo: First 100 books read Repo-)UI: progress.Report(100 of 5000) posted to the dispatcher UI->>VM: Progress callback sets the bound ProgressValue User->>UI: Click Cancel UI->>VM: LoadCancelCommand cancels the token Pool-->>Repo: Next check throws OperationCanceledException Repo-->>VM: The awaited task is canceled VM->>VM: Continuation resumes on the UI thread, sets StatusMessage

Two details are visible in the diagram. The progress callback and the continuation of LoadAsync both run on the UI thread, but for different reasons: the first because Progress<T> captured the context when it was created; the second because await captured it. The repository never touches the dispatcher; it only knows IProgress<T> and CancellationToken, which keeps it testable.

The repository contract is the canonical one of the section plus one extra overload of GetAllAsync that reports progress. The progress type is a small immutable record:

namespace Bookshelf.Core;

public sealed record Book(int Id, string Title, string Author, int Year = 0, decimal Price = 0m, string? Isbn = null);

/// <summary>A progress report: books read so far out of a known total.</summary>
public readonly record struct LoadProgress(int Loaded, int Total)
{
    public double Fraction => Total == 0 ? 1.0 : (double)Loaded / Total;
}

public interface IBookRepository
{
    Task<IReadOnlyList<Book>> GetAllAsync(CancellationToken cancellationToken);

    /// <summary>Reads all books, reporting progress. Safe to call from the UI thread.</summary>
    Task<IReadOnlyList<Book>> GetAllAsync(
        IProgress<LoadProgress>? progress,
        CancellationToken cancellationToken);

    Task<Book?> GetByIdAsync(int id, CancellationToken cancellationToken);
    Task<Book> AddAsync(Book book, CancellationToken cancellationToken);
    Task UpdateAsync(Book book, CancellationToken cancellationToken);
    Task DeleteAsync(int id, CancellationToken cancellationToken);
}

Source: Progress<T>

The implementation is a library-style class: it never references WPF, awaits with ConfigureAwait(false), checks the token between batches and reports at most every 100 books, so that the UI thread is not flooded with callbacks. The BookshelfDbContext is an EF Core context with SQLite, as in the scenario; any asynchronous data source behaves the same.

using Microsoft.EntityFrameworkCore;

namespace Bookshelf.Core;

public sealed class SqliteBookRepository(IDbContextFactory<BookshelfDbContext> contextFactory) : IBookRepository
{
    private const int ReportEvery = 100;

    public Task<IReadOnlyList<Book>> GetAllAsync(CancellationToken cancellationToken) =>
        GetAllAsync(progress: null, cancellationToken);

    public async Task<IReadOnlyList<Book>> GetAllAsync(
        IProgress<LoadProgress>? progress,
        CancellationToken cancellationToken)
    {
        // A short-lived context per operation: a DbContext is not thread-safe.
        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken)
            .ConfigureAwait(false);

        var total = await context.Books.CountAsync(cancellationToken).ConfigureAwait(false);
        var books = new List<Book>(total);

        await foreach (var entity in context.Books
            .AsNoTracking()
            .OrderBy(b => b.Title)
            .AsAsyncEnumerable()
            .WithCancellation(cancellationToken)
            .ConfigureAwait(false))
        {
            books.Add(new Book(entity.Id, entity.Title, entity.Author));
            if (books.Count % ReportEvery == 0)
            {
                progress?.Report(new LoadProgress(books.Count, total));
            }
        }

        progress?.Report(new LoadProgress(books.Count, total));
        return books;
    }

    public async Task<Book?> GetByIdAsync(int id, CancellationToken cancellationToken)
    {
        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken)
            .ConfigureAwait(false);
        var entity = await context.Books.AsNoTracking()
            .FirstOrDefaultAsync(b => b.Id == id, cancellationToken)
            .ConfigureAwait(false);
        return entity is null ? null : new Book(entity.Id, entity.Title, entity.Author);
    }

    public async Task<Book> AddAsync(Book book, CancellationToken cancellationToken)
    {
        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken)
            .ConfigureAwait(false);
        var entity = new BookEntity { Title = book.Title, Author = book.Author };
        context.Books.Add(entity);
        await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
        return book with { Id = entity.Id };
    }

    public async Task UpdateAsync(Book book, CancellationToken cancellationToken)
    {
        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken)
            .ConfigureAwait(false);
        await context.Books
            .Where(b => b.Id == book.Id)
            .ExecuteUpdateAsync(
                s => s.SetProperty(b => b.Title, book.Title).SetProperty(b => b.Author, book.Author),
                cancellationToken)
            .ConfigureAwait(false);
    }

    public async Task DeleteAsync(int id, CancellationToken cancellationToken)
    {
        await using var context = await contextFactory.CreateDbContextAsync(cancellationToken)
            .ConfigureAwait(false);
        await context.Books.Where(b => b.Id == id).ExecuteDeleteAsync(cancellationToken)
            .ConfigureAwait(false);
    }
}

The view model creates the Progress<LoadProgress> inside the command, which runs on the UI thread, so the progress object captures the DispatcherSynchronizationContext. The generated LoadCommand is an AsyncRelayCommand, which supplies the CancellationToken and, with IncludeCancelCommand = true, a LoadCancelCommand:

using System.Collections.ObjectModel;
using Bookshelf.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace Bookshelf.Desktop.ViewModels;

public partial class BooksViewModel(IBookRepository repository) : ObservableObject
{
    public ObservableCollection<BookViewModel> Books { get; } = [];

    [ObservableProperty]
    public partial double ProgressValue { get; set; }

    [ObservableProperty]
    public partial string? StatusMessage { get; set; }

    [RelayCommand(IncludeCancelCommand = true)]
    private async Task LoadAsync(CancellationToken cancellationToken)
    {
        ProgressValue = 0;
        StatusMessage = "Loading books...";

        // Created on the UI thread: captures its SynchronizationContext, so the
        // callback below also runs on the UI thread and may set bound properties.
        var progress = new Progress<LoadProgress>(p =>
        {
            ProgressValue = p.Fraction * 100;
            StatusMessage = $"Loading {p.Loaded} of {p.Total}";
        });

        try
        {
            // The repository awaits with ConfigureAwait(false) internally; the
            // continuation after this await is back on the UI thread.
            var books = await repository.GetAllAsync(progress, cancellationToken);

            Books.Clear();
            foreach (var book in books)
            {
                Books.Add(new BookViewModel(book));
            }

            StatusMessage = $"{Books.Count} books";
        }
        catch (OperationCanceledException)
        {
            StatusMessage = "Loading cancelled";
        }
        catch (Exception ex)
        {
            // By default the command rethrows on the UI thread, which crashes the
            // app if unhandled. Surface it as state instead.
            StatusMessage = $"Could not load books: {ex.Message}";
        }
    }
}

The view binds a progress bar to ProgressValue, shows it while the command runs (AsyncRelayCommand.IsRunning is an observable property of the command), and wires the two buttons. The same markup works unchanged on the commands page’s BooksViewModel:

<StackPanel Margin="12" Orientation="Vertical"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <StackPanel.Resources>
        <BooleanToVisibilityConverter x:Key="BoolToVisibility" />
    </StackPanel.Resources>

    <StackPanel Orientation="Horizontal">
        <Button Content="Load" Command="{Binding LoadCommand}" Margin="0,0,8,0" />
        <Button Content="Cancel" Command="{Binding LoadCancelCommand}" />
    </StackPanel>

    <ProgressBar Minimum="0" Maximum="100" Height="8"
                 Value="{Binding ProgressValue, Mode=OneWay}"
                 Visibility="{Binding LoadCommand.IsRunning, Converter={StaticResource BoolToVisibility}}" />

    <TextBlock Margin="0,8,0,0" Text="{Binding StatusMessage}" />
</StackPanel>

A few details are worth stating. LoadCommand is disabled while it runs (the toolkit’s AllowConcurrentExecutions defaults to false), so the user cannot start two loads, and LoadCancelCommand is enabled only while a load is running. Because LoadAsync catches OperationCanceledException itself, cancellation looks like a normal completion to the command; if the method let it escape, the AsyncRelayCommand would treat the task as canceled and IsCancellationRequested would be set, but the status message would be lost.

ConfigureAwait(false) in Libraries

By default await marshals the continuation back to the captured SynchronizationContext. That is the right thing in code that updates the UI after the await. In code that does not, it is a cost: each continuation is posted to the dispatcher queue and competes with input and rendering, and it makes a blocking caller deadlock (next section). ConfigureAwait(false) says that the continuation does not need the original context and may run wherever the task completed. The reference for Task.ConfigureAwait states both reasons: the default behaviour "can be costly in terms of performance and can result in a deadlock on the UI thread", and it points to the ConfigureAwait FAQ.

Code ConfigureAwait(false)? Reason

A view-model method that sets bound properties after the await

No

It must resume on the UI thread.

A repository, HTTP client wrapper, file service or any class library that knows nothing about WPF

Yes, on every await

It does not need the UI context, it may be called from any thread, and it protects callers that block on it.

A command or event handler (async void)

No

Handlers are the top of the UI stack and end with UI work.

Code after await x.ConfigureAwait(false) that touches an element

Never

It runs on a thread-pool thread and throws.

Unit tests of view models

Usually no

xUnit and MSTest have no UI context by default, so the choice is invisible there.

Rules that follow:

  • ConfigureAwait(false) applies to one await, not to the method. Every await in the library method needs it, or the first one that is missed resumes on the UI context. Analyzers such as CA2007 (Consider calling ConfigureAwait on the awaited task) can enforce it for a project; it is usually enabled for library projects only.

  • It matters less in an ASP.NET Core application (which has no synchronization context) than in a desktop one, which is why guidance written for web projects says it is optional. In WPF, WinUI and WinForms the context exists, so it is meaningful in every library used by the UI.

  • .NET 8 added ConfigureAwait(ConfigureAwaitOptions) with flags such as SuppressThrowing and ForceYielding, in addition to ContinueOnCapturedContext. They are rarely needed in application code; ConfigureAwait(false) remains the idiom.

  • ConfigureAwait(false) does not make code run on another thread. If the awaited task is already complete, the continuation continues synchronously on the same thread, with or without it.

The SqliteBookRepository above follows these rules. The view model keeps the default, and that is what puts the final updates back on the UI thread.

Progress Reporting

IProgress<T> is the .NET contract for "report a value to whoever is interested", and Progress<T> is its standard implementation. The reference for Progress<T> gives the property that matters in WPF: any handler passed to the constructor, and any handler subscribed to the ProgressChanged event, is invoked through the SynchronizationContext captured when the instance was constructed. If there is no current context at construction, the callbacks run on the thread pool.

Situation Result

new Progress<T>(...) created on the UI thread (in a command or event handler)

Callbacks run on the UI thread; they may set bound properties and update elements.

new Progress<T>(...) created inside a Task.Run lambda or on a worker thread

No context was captured; callbacks run on the thread pool and touching a bound property that raises PropertyChanged on an element-bound property throws or misbehaves. Create it before starting the work.

IProgress<T>.Report called from a worker

Does not block the worker: the callback is posted to the captured context and Report returns immediately.

A report arrives after the operation completed

It can: callbacks are posted, so a late callback may run after the awaiting code has resumed. Do not assume that the last report is processed before the await completes.

Conventions:

  • Report values, not mutable objects: a readonly record struct such as LoadProgress cannot be changed by the worker while the UI reads it.

  • Throttle. Reporting on every item of a 100 000-item loop posts 100 000 delegates and can saturate the dispatcher. Report on a fraction (every 100 items, every 1 %) or at a time interval. The loop in SqliteBookRepository reports every 100 books.

  • IProgress<T> is for monotonic status. For data (the books themselves), return them or stream them; do not smuggle them through a progress channel.

  • A null progress is valid and common (progress?.Report(...)), so the same method serves callers who do not care.

Cancellation

Cancellation in .NET is cooperative. The caller creates a CancellationTokenSource, hands the token down, and the callee checks it or passes it to APIs that observe it. A canceled operation signals itself by throwing OperationCanceledException, and the awaited task ends in the Canceled state.

using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace Bookshelf.Desktop.ViewModels;

public partial class CoverSearchViewModel(ICoverService covers) : ObservableObject
{
    private CancellationTokenSource? _search;

    [ObservableProperty]
    public partial string? Query { get; set; }

    [ObservableProperty]
    public partial Uri? CoverUri { get; set; }

    // Each new query cancels the previous one: only the latest result is shown.
    partial void OnQueryChanged(string? value) => _ = SearchAsync(value);

    private async Task SearchAsync(string? query)
    {
        _search?.Cancel();
        _search?.Dispose();
        var source = _search = new CancellationTokenSource();

        if (string.IsNullOrWhiteSpace(query))
        {
            CoverUri = null;
            return;
        }

        try
        {
            CoverUri = await covers.FindCoverAsync(query, source.Token);
        }
        catch (OperationCanceledException)
        {
            // A newer search replaced this one; nothing to show.
        }
        catch (Exception ex)
        {
            MessageBox.Show(ex.Message, "Cover search");
        }
    }
}

public interface ICoverService
{
    Task<Uri?> FindCoverAsync(string query, CancellationToken cancellationToken);
}

The listing shows three conventions that apply to every cancelable UI operation:

  • Last request wins. Typing a new query cancels the previous request. Without it, a slow first response can arrive after a fast second one and overwrite it.

  • Cancellation is not an error. Catch OperationCanceledException where the operation was started, and do not show it to the user.

  • Dispose the source. A CancellationTokenSource that registers callbacks or a timer holds resources; dispose it when replaced and when the view model is closed.

With the MVVM Toolkit the plumbing is automatic for commands: [RelayCommand] on a method with a CancellationToken parameter makes an AsyncRelayCommand that supplies the token and exposes Cancel(), and IncludeCancelCommand = true generates a ready-to-bind cancel command, as in BooksViewModel. CancelAfter on a source adds a timeout.

The method OnQueryChanged above returns void and discards the task (_ = SearchAsync(value)), which is the legitimate shape of a "fire and forget" started from a synchronous callback: SearchAsync catches everything itself, so no exception is lost. It is the only case where discarding a task is acceptable; the pitfalls below discuss async void, which is not the same.

Collections Changed from Other Threads

Data binding to a collection adds a second thread-affinity rule. An ItemsControl (ListView, DataGrid, TreeView) reads its collection through a CollectionView, and the view has affinity to the thread that created the control. If a worker thread adds an item to an ObservableCollection<T> that a list is bound to, CollectionChanged is raised on the worker thread, the view receives it there, and an exception tells you that "this type of CollectionView does not support changes to its SourceCollection from a thread different from the Dispatcher thread". The restriction applies to the collection in effect, as the reference says.

Three ways to handle it, in order of preference:

Approach How Notes

Fill on the UI thread

Produce a list on the worker, await it, then add to the collection on the UI thread (BooksViewModel.LoadAsync)

Simplest and always correct. Add in batches for large lists.

Dispatcher.InvokeAsync per change

The worker posts each Add to the dispatcher

Works for a trickle of events (a file watcher). A flood of posts saturates the queue; batch them.

BindingOperations.EnableCollectionSynchronization

Tell WPF which lock protects the collection, then change it from any thread under that lock

For long-lived collections updated by background producers; needs discipline described below.

EnableCollectionSynchronization has two overloads: one with the collection and a lock object (a simple lock mechanism), and one with the collection, a context object and a CollectionSynchronizationCallback for other mechanisms (semaphores, reader-writer locks, reset events). The reference lists the application’s responsibilities:

  1. Choose a synchronization mechanism, and use it for all application access to the collection.

  2. Call EnableCollectionSynchronization on the UI thread, and before the collection is used on another thread or attached to the ItemsControl, whichever is later.

  3. Make a change and its CollectionChanged notification atomic. An ObservableCollection<T> guarantees that when every change is protected by the lock.

  4. Call DisableCollectionSynchronization, if needed, on the UI thread too. To use the same collection from several UI threads, call EnableCollectionSynchronization separately on each.

  5. Avoid deadlock; WPF never takes a lock visible to the application on its own.

In return WPF keeps a shadow copy of the collection for the UI thread, queues CollectionChanged events as they arrive on any thread, applies them to the shadow copy on the UI thread when it has the opportunity, and throttles the rate so that background threads cannot saturate the UI thread. The net effect described in the reference: changes made on any thread eventually appear in the control "when the UI thread has time to catch up".

The class below owns a live collection of "recently added" books that is fed by a background import and shown in a ListView:

using System.Collections.ObjectModel;
using System.Windows.Data;
using Bookshelf.Core;

namespace Bookshelf.Desktop.ViewModels;

public sealed class RecentBooksFeed
{
    private readonly object _gate = new();

    public ObservableCollection<Book> Items { get; } = [];

    // Call on the UI thread, before the list is bound or the import starts.
    public void EnableSynchronization() =>
        BindingOperations.EnableCollectionSynchronization(Items, _gate);

    // Called from any thread, for example from an import running in Task.Run.
    public void Add(Book book)
    {
        lock (_gate)
        {
            Items.Add(book);
            if (Items.Count > 50)
            {
                Items.RemoveAt(0);
            }
        }
    }

    // Reads from application code must use the same lock.
    public int CountByAuthor(string author)
    {
        lock (_gate)
        {
            return Items.Count(b => b.Author == author);
        }
    }

    // Call on the UI thread when the feed is discarded.
    public void DisableSynchronization() =>
        BindingOperations.DisableCollectionSynchronization(Items);
}

Limitations that the reference’s wording implies. It synchronizes the collection, not the items: a Book record is immutable and safe, but a mutable BookViewModel raising PropertyChanged from a worker thread is a separate problem (change notifications for a property binding are marshalled to the UI thread by the binding engine, but code that reads or writes elements, brushes or other DispatcherObjects is not). The call must be repeated for every collection, and for a collection view it must precede the first use on another thread. Calling it from a worker thread is an error. Finally, the lock you choose must never be held while waiting for the UI thread, or you reintroduce the deadlock WPF’s design tries to prevent.

Deadlocks and How to Avoid Them

A deadlock in a WPF application is nearly always the UI thread waiting for something that needs the UI thread. The three recurring patterns:

Pattern Why it hangs Fix

.Result, .Wait() or .GetAwaiter().GetResult() on a task from UI code

The UI thread blocks until the task finishes. The task’s await inside is waiting to resume on the UI context, which is blocked. Neither can proceed.

await instead, all the way up. Use ConfigureAwait(false) inside libraries as insurance, not as a replacement.

Dispatcher.Invoke from a worker while the UI thread waits for that worker

The worker waits for the UI thread to run the delegate; the UI thread waits (Thread.Join, Task.Wait, lock) for the worker.

Never block the UI thread on a worker that calls Invoke. Use InvokeAsync, or await the worker.

A lock held across a dispatcher call

Thread A holds a lock and calls Invoke; the UI thread needs the same lock to proceed.

Do not call into the dispatcher while holding a lock; take the lock for the shortest time and release it first.

The first pattern needs no UI at all to reproduce, only a handler that blocks. It is the most common deadlock in desktop code, usually from a constructor or a property getter that cannot be async:

using Bookshelf.Core;
using CommunityToolkit.Mvvm.Input;

namespace Bookshelf.Desktop.ViewModels;

public partial class DeadlockDemoViewModel(IBookRepository repository)
{
    // Deadlocks if GetAllAsync uses the default context for any await:
    // the UI thread blocks on .Result, the continuation waits for the UI thread.
    [RelayCommand]
    private void LoadBlocking()
    {
        var books = repository.GetAllAsync(CancellationToken.None).Result;
        _ = books.Count;
    }

    // Correct: the thread is released while the query runs.
    [RelayCommand]
    private async Task LoadAsync()
    {
        var books = await repository.GetAllAsync(CancellationToken.None);
        _ = books.Count;
    }
}

Even when LoadBlocking does not deadlock because the repository uses ConfigureAwait(false), it still freezes the window until the query ends, which defeats the purpose. The correct fix is never to block; the one place blocking cannot be avoided is Main and a few legacy overrides, and there the call must go through Task.Run (Task.Run(() => DoAsync()).GetAwaiter().GetResult()) so that the async code does not capture the UI context.

Techniques that avoid deadlocks in general:

  • Go async all the way. A synchronous method that must call async code should become async, and so on up to the event handler or command.

  • Replace "constructor loads data" with a factory or an InitializeAsync that the view invokes (for example from a Loaded handler or a command). A property getter must never wait for a task.

  • Keep lock scopes short, never await inside a lock (the compiler forbids it), and use SemaphoreSlim.WaitAsync for an asynchronous mutual exclusion.

  • Prefer InvokeAsync to Invoke. Invoke is acceptable when the worker truly cannot continue without the UI result; give it a timeout overload if the UI thread may be busy for long.

  • Use the debugger: Debug > Windows > Parallel Stacks and the Threads window show what the UI thread is blocked on, as the tooling page of this section describes.

Multiple UI Threads

The Learn article explains that one thread and one dispatcher can manage many windows, but sometimes several threads do better, especially when a window may monopolize the thread. Examples are a heavy rendering preview or a long scrolling log. Each such thread needs its own dispatcher and message loop:

using System.Windows;
using System.Windows.Threading;

namespace Bookshelf.Desktop.Threading;

public static class IndependentWindow
{
    public static void Show<TWindow>() where TWindow : Window, new()
    {
        var thread = new Thread(() =>
        {
            var window = new TWindow();
            window.Show();

            // When the window closes, shut its dispatcher down so the thread can exit.
            window.Closed += (_, _) => window.Dispatcher.InvokeShutdown();

            // Starts the message loop for this thread until InvokeShutdown.
            Dispatcher.Run();
        });

        // WPF requires STA threads for windows.
        thread.SetApartmentState(ApartmentState.STA);
        thread.IsBackground = true;
        thread.Start();
    }
}

Source: Threading model

Elements created on this thread cannot be used by the main window and the other way around, and the two windows have different Dispatcher objects. Data shared between them must be immutable or frozen (Freezable.Freeze()), or sent through Dispatcher.InvokeAsync on the other thread. Application.DispatcherUnhandledException fires only for the application’s own dispatcher, so the extra thread needs its own Dispatcher.UnhandledException subscription (the page on the application model discusses the exception hooks). Bookshelf Desktop does not need this technique; it is shown because it explains why Dispatcher.CurrentDispatcher is per thread.

Exceptions in Asynchronous Code

An unhandled exception takes a different route depending on how the code ran:

Code Where an unhandled exception ends up

An event handler, command execute method, Invoke delegate

Application.DispatcherUnhandledException; if not handled, the application terminates.

An async Task method that is awaited

Rethrown at the await in the caller, so the caller’s try/catch sees it.

An async Task method that nobody awaits

Stored in the task. It is unobserved; the runtime raises TaskScheduler.UnobservedTaskException when the task is garbage collected, and by default does not crash the process. The error is effectively lost.

An async void method (handlers, partial void On...Changed hooks that start async work)

Thrown on the context captured when the method started, which for a UI thread means it reaches the dispatcher and, unhandled, crashes the application.

An AsyncRelayCommand

Rethrown on the UI thread by default, so an unhandled exception crashes the application like an async void handler. With AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler it is stored in ExecutionTask instead, which the view model can inspect (catching inside, as LoadAsync does, is simpler).

Two practical rules follow. Catch exceptions inside the asynchronous method that the user started and turn them into a visible state (StatusMessage, an error dialog through IDialogService). Register Application.DispatcherUnhandledException, AppDomain.UnhandledException and TaskScheduler.UnobservedTaskException handlers that log, as the page on the WPF architecture shows, as the last line of defense and not as normal error handling.

Threading Across the Windows UI Stacks

Every Windows UI stack has the same rule, UI objects belong to one thread, and a different API for the same mechanism. A table makes the vocabulary portable:

Concept WPF WinUI 3 / Windows App SDK Windows Forms .NET MAUI

Thread affinity base

DispatcherObject

DependencyObject.DispatcherQueue

Control (the handle belongs to a thread)

Platform views; check the IDispatcher

Queue object

Dispatcher

DispatcherQueue

The message queue behind SynchronizationContext

IDispatcher (Dispatcher property of a view)

Are we on the UI thread?

Dispatcher.CheckAccess()

DispatcherQueue.HasThreadAccess

Control.InvokeRequired

Dispatcher.IsDispatchRequired

Post to the UI thread

Dispatcher.InvokeAsync, BeginInvoke

DispatcherQueue.TryEnqueue

Control.BeginInvoke, Control.InvokeAsync (.NET 9 and later)

Dispatcher.Dispatch, MainThread.BeginInvokeOnMainThread

Priority

DispatcherPriority (11 values from Inactive to Send)

DispatcherQueuePriority (Low, Normal, High)

None

None

Context for await

DispatcherSynchronizationContext

A WinUI synchronization context

WindowsFormsSynchronizationContext

Platform-specific

Timer

DispatcherTimer

DispatcherQueueTimer (and DispatcherTimer)

System.Windows.Forms.Timer

IDispatcherTimer

Collection change from a worker

EnableCollectionSynchronization (WPF only)

Not available; marshal to the UI thread

BindingList<T> or BindingSource must be changed on the UI thread

Marshal to the UI thread

async, Progress<T>, CancellationToken and ConfigureAwait behave the same on all four stacks; the code in BooksViewModel and SqliteBookRepository is reusable. What changes is the "how do I get back to the UI thread" step, and an application that targets more than one stack should keep it behind a tiny abstraction (an IUiDispatcher with RunAsync) so that view models and tests do not depend on Dispatcher. The page on XAML dialects lists the same mapping as one of the nine differences between WPF, WinUI and MAUI. The details of the WinUI, WinForms and MAUI APIs in this table are summarised here and are developed on those stacks' own pages; this page does not reproduce their documentation.

From .NET Framework to .NET 8, 9 and 10

Version Change relevant to this page

.NET Framework 3.0 and 3.5

Dispatcher, BeginInvoke, Invoke, DispatcherTimer and DispatcherPriority are introduced with WPF.

.NET Framework 4.0

The task-based pattern (Task, CancellationToken) arrives; Dispatcher gains no Task support yet.

.NET Framework 4.5

async and await; Dispatcher.InvokeAsync and DispatcherOperation.Task; Progress<T>; BindingOperations.EnableCollectionSynchronization for multi-threaded collections.

.NET Core 3.0 and .NET 5 onward

WPF is open source and runs on .NET. The threading API is the same; the contents of the Learn article reflect .NET Framework-era wording, but the behaviour is unchanged.

.NET 8

Task.ConfigureAwait(ConfigureAwaitOptions) is available.

.NET 9 and .NET 10

No change to the WPF dispatcher, timer or collection-synchronization APIs. Windows Forms gets Control.InvokeAsync and asynchronous dialog APIs, which are a WinForms matter.

.NET 11 (RC2)

The WPF release notes list no new threading APIs.

Pitfalls, Performance and Security

  • Blocking the UI thread. Thread.Sleep, Task.Wait, .Result, synchronous file or network I/O and long loops in a handler all freeze the window. Symptoms are a "Not responding" title bar and no repaint. Move CPU work to Task.Run and use the asynchronous API for I/O.

  • async void. Allowed only for event handlers and for partial void On...Changed callbacks that start work. An async void method cannot be awaited, so the caller cannot know when it finished or catch its exception, and an exception crashes the process through the dispatcher. Use async Task and [RelayCommand], which wraps the task for you.

  • Dispatcher.CurrentDispatcher on a worker thread. It creates a dispatcher for that thread that nobody runs, and work posted to it never executes. Use Application.Current.Dispatcher, or the dispatcher of an element.

  • Capturing the context in Task.Run. Code inside Task.Run(() => ...) runs on the pool: no element, no bound property, no ObservableCollection bound to a list. Compute there, assign after the await.

  • Invoke where InvokeAsync would do. Synchronous marshalling keeps a worker thread parked until the UI thread is free and is the usual ingredient of dispatcher deadlocks.

  • Flooding the dispatcher. Posting one item per record, or reporting progress per item, queues thousands of items. Batch: collect 100 to 1 000 changes and apply them in one InvokeAsync, or report on a fraction.

  • Priorities as a fix for slowness. Lowering a priority makes the UI feel better only when the work is already short; a single long Background item still blocks the thread for its entire duration.

  • Forgetting to unsubscribe. A DispatcherTimer and an event on a long-lived object keep the subscriber alive. Stop and unsubscribe in Dispose, when the window closes.

  • Unthrottled collection synchronization. EnableCollectionSynchronization queues one event per change; for a bulk replace, build a new collection or use a batch pattern, then assign it once.

  • Reading Application.Current when it is null. In unit tests and during shutdown there is no application. A testable view model depends on an abstraction for "run on the UI thread", not on Application.Current.

  • Exceptions lost in fire-and-forget code. A discarded task hides its exception. Catch inside the method that you discard, as in SearchAsync.

  • Security and robustness. Cancellation tokens are the mechanism for bounding work started by untrusted input (for example a file the user opened); a missing token on a loop over such input is a denial-of-service of the application’s own UI. Do not marshal user-controlled delegates or strings into Dispatcher calls that evaluate them, and do not include sensitive data in progress messages that a screen reader or logs may expose.

  • Performance. The dispatcher cost of await on the UI context is small, but a hot loop that awaits per item pays it each time; await once for a batch. ConfigureAwait(false) in libraries removes unnecessary round trips. Measure with the Performance Profiler (CPU Usage and the .NET Async tool) before optimizing.

What Microsoft Learn Does Not Provide

  • There is no WPF-specific MVVM guidance for asynchronous commands, progress and cancellation. The toolkit’s AsyncRelayCommand page covers the command; the view model in this page is a pattern, not a Learn sample.

  • There is no recommendation on batch sizes, throttling intervals or the number of progress reports. The values in the examples (100 books, 300 ms) are choices for the Bookshelf scenario; measure for your data.

  • The Learn threading article still shows the pre-async samples (a BeginInvoke loop for prime numbers) at length and mentions Task.Run only in a later section. It does not discuss ConfigureAwait.

  • DispatcherSynchronizationContext is documented by its constructors and methods only; the mapping of await continuations to a priority (the default constructors use Normal, as far as the class page describes it) is not explained.

  • The behaviour of Dispatcher.Yield is described in one sentence per overload; there is no sample.

  • The interplay between EnableCollectionSynchronization and virtualization or grouping is not documented; test with your own control and view.

  • The cross-stack API names in the comparison table come from each stack’s own reference and are summarized, not restated in a WPF article.