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.InvokeAsyncandBindingOperations.EnableCollectionSynchronizationexist 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
DispatcherQueuecomparison. -
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,
InvokeversusBeginInvoke, the priority levels, multiple windows on multiple threads,Task.Run, nested pumping and reentrancy. Its older examples useBeginInvokewith delegates; the newer part (blocking operations) usesTask.Runandawait. -
The .NET API reference documents
Dispatcher,DispatcherPriority,DispatcherOperation,DispatcherTimer,DispatcherSynchronizationContextandBindingOperations.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.ConfigureAwaitreference 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 |
|---|---|
|
The |
|
Returns |
|
Throws |
|
The same two checks on the dispatcher itself, used when you hold a |
|
The thread the dispatcher belongs to. |
|
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. |
|
The dispatcher of the thread that created the |
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 |
|---|---|
|
Post a delegate to the queue. Covered in the next sections. |
|
Pushes the main execution frame, that is, starts the message loop. |
|
Enters a nested loop that keeps processing the queue until the frame’s |
|
Stop the dispatcher, synchronously or asynchronously. |
|
Observe shutdown. |
|
Raised when a delegate run through |
|
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. |
|
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. |
|
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 |
|---|---|---|
|
-1 |
Not a usable priority; passing it throws. |
|
0 |
Operations are not processed. An item queued at |
|
1 |
Processed when the system is idle. |
|
2 |
Processed when the application is idle. |
|
3 |
Processed after background operations have completed. |
|
4 |
Processed after all other non-idle operations are completed. |
|
5 |
Same priority as input. |
|
6 |
After layout and render have finished but just before items at |
|
7 |
Same priority as rendering. |
|
8 |
Same priority as data binding. |
|
9 |
The typical application priority, and the default for |
|
10 |
Processed before other asynchronous operations; the highest priority. An operation posted with |
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
Normalfor results the user is waiting for, such as showing the loaded books. -
Use
Backgroundfor work that can wait until input and rendering have been served, such as building a secondary view or updating a non-visible count. WithBackground, a stream of updates cannot starve input. -
Do not use
Send,Inactiveor the idle priorities without a reason you can name. A flood ofSenditems starves rendering and input. -
Inputpriority exists so that code can place work behind pending user input;RenderandDataBindare 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 |
|---|---|---|---|
|
Yes, until the UI thread has run the delegate |
Nothing, or the delegate’s result with the |
Synchronous. Overloads with a |
|
No |
|
The original asynchronous API (since WPF 3.0). Takes a delegate and an |
|
No |
|
Added in .NET Framework 4.5. Takes an |
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 |
|---|---|
|
|
|
Readable and settable. Changing it moves the item in the queue. |
|
Removes a pending item. It returns |
|
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. |
|
A |
|
The return value of the delegate, after completion. |
|
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);
}
}
Source: Dispatcher.InvokeAsync
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):
InvokeAsyncwithawaitwhere the caller is async,Invokeonly when it is not.Invokealso 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:
InvokeAsyncwithDispatcherPriority.LoadedorBackground. It defers the delegate behind work already queued. -
From modern
asynccode that started on the UI thread: you rarely need the dispatcher at all, as the section onasyncandawaitshows.
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 |
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
awaitin a UI event handler, a command or a view-model method resumes on the UI thread. Aftervar books = await repository.GetAllAsync(ct);the next line can add to anObservableCollectionbound to a list, with no dispatcher call. -
An
awaiton a worker thread (insideTask.Run, or afterConfigureAwait(false)) has no context to return to and resumes on a thread-pool thread, where touching an element throws. -
The code before the first
awaitruns 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 |
|---|---|---|
|
UI thread |
Use the result directly, including changing bound properties and collections. |
|
|
Do not touch elements or bound collections inside the lambda; return a value. |
|
A thread-pool thread |
Fine in a library or repository; wrong in a method that updates the UI afterwards. |
|
That worker’s context, usually the thread pool |
Marshal UI changes with |
|
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:
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);
}
}
Source: Task.ConfigureAwait
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}";
}
}
}
Source: AsyncRelayCommand
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>
Source: RelayCommand attribute
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 ( |
No |
Handlers are the top of the UI stack and end with UI work. |
Code after |
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. Everyawaitin 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 asSuppressThrowingandForceYielding, in addition toContinueOnCapturedContext. 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 |
|---|---|
|
Callbacks run on the UI thread; they may set bound properties and update elements. |
|
No context was captured; callbacks run on the thread pool and touching a bound property that raises
|
|
Does not block the worker: the callback is posted to the captured context and |
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 |
Conventions:
-
Report values, not mutable objects: a
readonly record structsuch asLoadProgresscannot 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
SqliteBookRepositoryreports 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
nullprogress 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);
}
Source: Cancellation in managed threads
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
OperationCanceledExceptionwhere the operation was started, and do not show it to the user. -
Dispose the source. A
CancellationTokenSourcethat 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, |
Simplest and always correct. Add in batches for large lists. |
|
The worker posts each |
Works for a trickle of events (a file watcher). A flood of posts saturates the queue; batch them. |
|
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:
-
Choose a synchronization mechanism, and use it for all application access to the collection.
-
Call
EnableCollectionSynchronizationon the UI thread, and before the collection is used on another thread or attached to theItemsControl, whichever is later. -
Make a change and its
CollectionChangednotification atomic. AnObservableCollection<T>guarantees that when every change is protected by the lock. -
Call
DisableCollectionSynchronization, if needed, on the UI thread too. To use the same collection from several UI threads, callEnableCollectionSynchronizationseparately on each. -
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 |
|---|---|---|
|
The UI thread blocks until the task finishes. The task’s |
|
|
The worker waits for the UI thread to run the delegate; the UI thread waits ( |
Never block the UI thread on a worker that calls |
A lock held across a dispatcher call |
Thread A holds a lock and calls |
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;
}
}
Source: Task.ConfigureAwait
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
InitializeAsyncthat the view invokes (for example from aLoadedhandler or a command). A property getter must never wait for a task. -
Keep lock scopes short, never
awaitinside alock(the compiler forbids it), and useSemaphoreSlim.WaitAsyncfor an asynchronous mutual exclusion. -
Prefer
InvokeAsynctoInvoke.Invokeis 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, |
|
An |
Rethrown at the |
An |
Stored in the task. It is |
An |
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 |
Rethrown on the UI thread by default, so an unhandled exception crashes the application like an |
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 |
|
|
|
Platform views; check the |
Queue object |
|
|
The message queue behind |
|
Are we on the UI thread? |
|
|
|
|
Post to the UI thread |
|
|
|
|
Priority |
|
|
None |
None |
Context for |
|
A WinUI synchronization context |
|
Platform-specific |
Timer |
|
|
|
|
Collection change from a worker |
|
Not available; marshal to 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 |
|
.NET Framework 4.0 |
The task-based pattern ( |
.NET Framework 4.5 |
|
.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 |
|
.NET 9 and .NET 10 |
No change to the WPF dispatcher, timer or collection-synchronization APIs. Windows Forms gets |
.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 toTask.Runand use the asynchronous API for I/O. -
async void. Allowed only for event handlers and forpartial void On...Changedcallbacks that start work. Anasync voidmethod cannot be awaited, so the caller cannot know when it finished or catch its exception, and an exception crashes the process through the dispatcher. Useasync Taskand[RelayCommand], which wraps the task for you. -
Dispatcher.CurrentDispatcheron a worker thread. It creates a dispatcher for that thread that nobody runs, and work posted to it never executes. UseApplication.Current.Dispatcher, or the dispatcher of an element. -
Capturing the context in
Task.Run. Code insideTask.Run(() => ...)runs on the pool: no element, no bound property, noObservableCollectionbound to a list. Compute there, assign after the await. -
InvokewhereInvokeAsyncwould 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
Backgrounditem still blocks the thread for its entire duration. -
Forgetting to unsubscribe. A
DispatcherTimerand an event on a long-lived object keep the subscriber alive. Stop and unsubscribe inDispose, when the window closes. -
Unthrottled collection synchronization.
EnableCollectionSynchronizationqueues one event per change; for a bulk replace, build a new collection or use a batch pattern, then assign it once. -
Reading
Application.Currentwhen it isnull. 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 onApplication.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
Dispatchercalls 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
awaiton 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
AsyncRelayCommandpage 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-
asyncsamples (aBeginInvokeloop for prime numbers) at length and mentionsTask.Runonly in a later section. It does not discussConfigureAwait. -
DispatcherSynchronizationContextis documented by its constructors and methods only; the mapping ofawaitcontinuations to a priority (the default constructors useNormal, as far as the class page describes it) is not explained. -
The behaviour of
Dispatcher.Yieldis described in one sentence per overload; there is no sample. -
The interplay between
EnableCollectionSynchronizationand 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.
References
-
WPF Architecture & Application Model for
DispatcherObjectand the unhandled-exception hooks -
Commands for
AsyncRelayCommandand[RelayCommand] -
Data Binding for
ObservableCollection<T>andUpdateSourceTrigger -
Lists, Grids & Trees for virtualization of large collections
-
XAML Dialects: WPF, WinUI & MAUI for the threading differences between stacks
-
Development Environment & Tooling for the debugger windows