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 after await runs on the UI thread and may touch bound collections directly.
  • Never call .Result, .Wait() or Task.WaitAll on 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 Task for 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;
    }
}
WorkApproachWhy
HTTP callawait httpClient.GetAsyncI/O bound; no thread is held
Large file parseawait Task.Run(...)CPU bound; keeps the dispatcher free
Progress reportingIProgress<T>Marshals to the captured context safely
Periodic refreshDispatcherTimerRuns on the UI thread, so binding is safe
Very frequent updatesCoalesce then dispatchOne dispatch per frame beats thousands
Blocking legacy APITask.Run wrapperThe 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);
  • Send is synchronous and can deadlock if called from the same thread; BeginInvoke and InvokeAsync are safe.
  • DispatcherPriority.Render and above compete with input - keep anything optional at Background.
  • 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.

Navigation, windows and dialogs WPF performance and testing MVVM applications

Last refreshed 2026-09-18.