Asynchronous work and UI responsiveness
await on the dispatcher, Task.Run for CPU work, progress and cancellation, dispatcher priorities, and how to keep a window from freezing.
await and the dispatcher
public sealed class OrdersViewModel : ObservableObject
{
private readonly IOrderApi _api;
public async Task LoadAsync()
{
IsBusy = true;
try
{
var orders = await _api.GetOrdersAsync(); // resumes on the UI thread
Orders.Clear();
foreach (var o in orders) Orders.Add(o);
}
catch (HttpRequestException ex)
{
Error = "Could not load orders: " + ex.Message;
}
finally
{
IsBusy = false;
}
}
}- WPF installs a
DispatcherSynchronizationContext, so the code afterawaitruns on the UI thread and may touch bound collections directly. - Never call
.Result,.Wait()orTask.WaitAllon the UI thread - the continuation needs that same thread and you get a deadlock. ConfigureAwait(false)in a view model is usually wrong: the continuation would leave the UI thread. It is right in library code.- Use
async Taskfor a command handler and make the command itself async-aware, otherwise exceptions vanish into an unobserved task.
CPU work, progress and cancellation
private CancellationTokenSource _cts;
public async Task ImportAsync(string path)
{
_cts?.Cancel();
_cts = new CancellationTokenSource();
var progress = new Progress<double>(p =>
{
ProgressValue = p * 100; // raised on the UI thread automatically
});
try
{
var rows = await Task.Run(() => _importer.Run(path, progress, _cts.Token), _cts.Token);
Status = "Imported " + rows + " rows.";
}
catch (OperationCanceledException)
{
Status = "Cancelled.";
}
finally
{
_cts.Dispose();
_cts = null;
}
}| Work | Approach | Why |
|---|---|---|
| HTTP call | await httpClient.GetAsync | I/O bound; no thread is held |
| Large file parse | await Task.Run(...) | CPU bound; keeps the dispatcher free |
| Progress reporting | IProgress<T> | Marshals to the captured context safely |
| Periodic refresh | DispatcherTimer | Runs on the UI thread, so binding is safe |
| Very frequent updates | Coalesce then dispatch | One dispatch per frame beats thousands |
| Blocking legacy API | Task.Run wrapper | The only way to keep the UI alive |
// update a chart at most ~30 times a second instead of on every data point
private readonly DispatcherTimer _render = new() { Interval = TimeSpan.FromMilliseconds(33) };
public App()
{
_render.Tick += (s, e) => { Render(_pending); };
}Dispatcher priorities and frozen windows
// let the current layout finish before scrolling or focusing
Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() =>
{
Results.ScrollIntoView(Results.SelectedItem);
}));
// keep a background read off the critical path
await Dispatcher.InvokeAsync(() =>
{
Status = "Ready";
}, DispatcherPriority.Background);Sendis synchronous and can deadlock if called from the same thread;BeginInvokeandInvokeAsyncare safe.DispatcherPriority.Renderand above compete with input - keep anything optional atBackground.- A frozen window is almost always synchronous I/O, a
.Result, or a loop running on the dispatcher. Profile the UI thread rather than adding a busy indicator.
⚠️
Adding a spinner does not make an application responsive. If the work is still on the dispatcher, the spinner will not animate either. Move the work, then show progress.
FAQ
Can I update an ObservableCollection from a background thread?
Not safely. WPF throws when a bound collection changes off the UI thread. Build the results off-thread and assign them on the UI thread, or use a collection that marshals internally.
Is async void ever acceptable?
Only for top-level event handlers. Anywhere else it hides the task, so no caller can await it, and exceptions surface as unhandled crashes.
Related
Navigation, windows and dialogs WPF performance and testing MVVM applications
Last refreshed 2026-09-18.