async
Task.Run
MVVM
asynchronous programming
C#

async Task.Run with MVVM

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In MVVM (Model-View-ViewModel) applications, Task.Run offloads CPU-bound work to a background thread so the UI stays responsive. The key challenge is updating UI-bound properties from the background thread — WPF, UWP, and MAUI require property changes to be raised on the UI thread. Use async/await with Task.Run in your ViewModel commands, and the continuation after await automatically returns to the UI thread (when using the default SynchronizationContext).

Basic Pattern

csharp
1public class MainViewModel : INotifyPropertyChanged
2{
3    private string _status;
4    public string Status
5    {
6        get => _status;
7        set { _status = value; OnPropertyChanged(); }
8    }
9
10    private bool _isBusy;
11    public bool IsBusy
12    {
13        get => _isBusy;
14        set { _isBusy = value; OnPropertyChanged(); }
15    }
16
17    public ICommand LoadDataCommand { get; }
18
19    public MainViewModel()
20    {
21        LoadDataCommand = new RelayCommand(async () => await LoadDataAsync());
22    }
23
24    private async Task LoadDataAsync()
25    {
26        IsBusy = true;  // UI thread — safe to update
27        Status = "Loading...";
28
29        var result = await Task.Run(() =>
30        {
31            // Background thread — CPU-bound work
32            Thread.Sleep(2000);  // Simulate heavy computation
33            return "Data loaded successfully";
34        });
35
36        // Back on UI thread after await
37        Status = result;  // Safe to update bound property
38        IsBusy = false;
39    }
40
41    public event PropertyChangedEventHandler PropertyChanged;
42    protected void OnPropertyChanged([CallerMemberName] string name = null)
43        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
44}

After await Task.Run(...), execution resumes on the UI thread because WPF/MAUI captures the SynchronizationContext. This means you can safely update bound properties without explicit dispatching.

When to Use Task.Run

csharp
1// USE Task.Run for CPU-bound work
2await Task.Run(() => ComputeHeavyAlgorithm(data));
3await Task.Run(() => ParseLargeFile(filePath));
4await Task.Run(() => EncryptData(payload));
5
6// DO NOT use Task.Run for I/O-bound work — use async APIs directly
7var data = await httpClient.GetStringAsync(url);      // Already async
8var content = await File.ReadAllTextAsync(filePath);   // Already async
9var result = await dbContext.Users.ToListAsync();       // Already async

Task.Run adds thread pool overhead. For I/O-bound operations, the built-in async methods are more efficient because they do not consume a thread while waiting.

Async RelayCommand Implementation

csharp
1public class AsyncRelayCommand : ICommand
2{
3    private readonly Func<Task> _execute;
4    private readonly Func<bool> _canExecute;
5    private bool _isExecuting;
6
7    public AsyncRelayCommand(Func<Task> execute, Func<bool> canExecute = null)
8    {
9        _execute = execute;
10        _canExecute = canExecute;
11    }
12
13    public bool CanExecute(object parameter)
14        => !_isExecuting && (_canExecute?.Invoke() ?? true);
15
16    public async void Execute(object parameter)
17    {
18        if (!CanExecute(parameter)) return;
19
20        _isExecuting = true;
21        RaiseCanExecuteChanged();
22
23        try
24        {
25            await _execute();
26        }
27        finally
28        {
29            _isExecuting = false;
30            RaiseCanExecuteChanged();
31        }
32    }
33
34    public event EventHandler CanExecuteChanged;
35
36    public void RaiseCanExecuteChanged()
37        => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
38}

This command disables itself while executing, preventing double-clicks from launching parallel operations.

Reporting Progress from Task.Run

csharp
1private async Task ProcessItemsAsync()
2{
3    IsBusy = true;
4    var progress = new Progress<int>(percent =>
5    {
6        // This callback runs on the UI thread
7        ProgressValue = percent;
8    });
9
10    await Task.Run(() =>
11    {
12        var items = GetLargeDataSet();
13        for (int i = 0; i < items.Count; i++)
14        {
15            ProcessItem(items[i]);
16            ((IProgress<int>)progress).Report((i + 1) * 100 / items.Count);
17        }
18    });
19
20    IsBusy = false;
21    Status = "Processing complete";
22}

Progress<T> captures the SynchronizationContext at construction time, so its callback always runs on the UI thread. Create it before entering Task.Run.

Cancellation Support

csharp
1private CancellationTokenSource _cts;
2
3public ICommand CancelCommand { get; }
4
5private async Task LoadDataAsync()
6{
7    _cts = new CancellationTokenSource();
8    IsBusy = true;
9
10    try
11    {
12        var result = await Task.Run(() =>
13        {
14            for (int i = 0; i < 100; i++)
15            {
16                _cts.Token.ThrowIfCancellationRequested();
17                Thread.Sleep(50);  // Simulate work
18            }
19            return "Complete";
20        }, _cts.Token);
21
22        Status = result;
23    }
24    catch (OperationCanceledException)
25    {
26        Status = "Cancelled";
27    }
28    finally
29    {
30        IsBusy = false;
31        _cts.Dispose();
32    }
33}
34
35private void Cancel() => _cts?.Cancel();

Pass the CancellationToken to Task.Run and check it periodically inside the delegate. This lets users cancel long-running operations via a Cancel button bound to CancelCommand.

Error Handling

csharp
1private async Task LoadDataAsync()
2{
3    IsBusy = true;
4    ErrorMessage = null;
5
6    try
7    {
8        var result = await Task.Run(() =>
9        {
10            // This might throw
11            return RiskyComputation();
12        });
13        Status = result;
14    }
15    catch (InvalidOperationException ex)
16    {
17        ErrorMessage = $"Operation failed: {ex.Message}";
18    }
19    catch (Exception ex)
20    {
21        ErrorMessage = $"Unexpected error: {ex.Message}";
22    }
23    finally
24    {
25        IsBusy = false;
26    }
27}

Exceptions thrown inside Task.Run are captured by the Task and re-thrown at the await point. The catch block runs on the UI thread, so you can safely update error-display properties.

Thread Safety with ObservableCollection

csharp
1// ObservableCollection raises CollectionChanged on the calling thread
2// WPF requires CollectionChanged on the UI thread
3
4// Option 1: Collect results, then update on UI thread
5private async Task LoadItemsAsync()
6{
7    var items = await Task.Run(() => FetchAndProcessItems());
8
9    Items.Clear();
10    foreach (var item in items)
11    {
12        Items.Add(item);  // UI thread — safe
13    }
14}
15
16// Option 2: Use dispatcher for incremental updates
17private async Task LoadItemsIncrementalAsync()
18{
19    await Task.Run(() =>
20    {
21        foreach (var item in FetchItems())
22        {
23            App.Current.Dispatcher.Invoke(() => Items.Add(item));
24        }
25    });
26}

Never modify an ObservableCollection from a background thread in WPF. Either batch the results and add them after await, or use Dispatcher.Invoke for incremental updates.

MVVM Toolkit (CommunityToolkit.Mvvm)

csharp
1using CommunityToolkit.Mvvm.ComponentModel;
2using CommunityToolkit.Mvvm.Input;
3
4public partial class MainViewModel : ObservableObject
5{
6    [ObservableProperty]
7    private string _status;
8
9    [ObservableProperty]
10    private bool _isBusy;
11
12    [RelayCommand]
13    private async Task LoadDataAsync()
14    {
15        IsBusy = true;
16        Status = await Task.Run(() => HeavyComputation());
17        IsBusy = false;
18    }
19}

The [RelayCommand] source generator creates LoadDataCommand automatically, including async support with built-in CanExecute management.

Common Pitfalls

  • Using Task.Run for I/O: await Task.Run(() => httpClient.GetAsync(url)) wastes a thread pool thread. Use await httpClient.GetAsync(url) directly — I/O-bound async operations do not need Task.Run.
  • Updating UI properties inside Task.Run: Setting bound properties inside Task.Run(() => { Status = "Done"; }) raises PropertyChanged on a background thread, which can crash or silently fail in WPF. Always update UI properties after the await.
  • Fire-and-forget commands: async void Execute() in commands swallows exceptions silently if not wrapped in try/catch. Always add error handling inside async void methods.
  • Missing ConfigureAwait: In library code (not ViewModels), use await Task.Run(...).ConfigureAwait(false) to avoid capturing the synchronization context unnecessarily. In ViewModels, the default behavior (capturing context) is what you want.
  • Blocking with .Result or .Wait(): Calling Task.Run(...).Result or .Wait() blocks the UI thread and can cause deadlocks. Always use await to consume async results in ViewModels.

Summary

  • Use Task.Run in MVVM ViewModels to offload CPU-bound work to a background thread
  • After await Task.Run(...), execution returns to the UI thread — safe to update bound properties
  • Do not use Task.Run for I/O-bound work — use native async APIs instead
  • Use Progress<T> for progress reporting from background threads
  • Use CancellationTokenSource for cancellable operations
  • Never modify ObservableCollection from a background thread in WPF
  • Consider CommunityToolkit.Mvvm for source-generated async commands

Course illustration
Course illustration

All Rights Reserved.