Goal
Why this matters
Prerequisites
Avalonia has a single UI thread managed by Dispatcher.UIThread. UI elements and bound properties must be updated on this thread.
Rules of thumb:
Task.Run to offload to a thread pool thread.Dispatcher.UIThread.Post/InvokeAsync to marshal back to the UI thread if needed (though Progress<T> usually keeps you on the UI thread).await Dispatcher.UIThread.InvokeAsync(() => Status = "Ready");
DispatcherPriority controls when queued work runs relative to layout, input, and rendering. Use Dispatcher.UIThread.Post with an explicit priority when you want work to wait until after animations or to run ahead of rendering.
Dispatcher.UIThread.Post(
() => Notifications.Clear(),
priority: DispatcherPriority.Background);
Dispatcher.UIThread.Post(
() => Toasts.Enqueue(message),
priority: DispatcherPriority.Input);
Avoid defaulting everything to DispatcherPriority.Send (synchronous) because it can starve input processing.
DispatcherSynchronizationContext is installed on the UI thread; async continuations captured there automatically hop back to Avalonia when you await. When running background tasks (e.g., unit tests or hosted services) ensure you resume on the UI thread by capturing the context:
var uiContext = SynchronizationContext.Current;
await Task.Run(async () =>
{
var result = await LoadAsync(ct).ConfigureAwait(false);
uiContext?.Post(_ => ViewModel.Result = result, null);
});
When you intentionally want to stay on a background thread, use ConfigureAwait(false) to avoid marshaling back.
public sealed class WorkViewModel : ObservableObject
{
private CancellationTokenSource? _cts;
private double _progress;
private string _status = "Idle";
private bool _isBusy;
public double Progress { get => _progress; set => SetProperty(ref _progress, value); }
public string Status { get => _status; set => SetProperty(ref _status, value); }
public bool IsBusy { get => _isBusy; set => SetProperty(ref _isBusy, value); }
public RelayCommand StartCommand { get; }
public RelayCommand CancelCommand { get; }
public WorkViewModel()
{
StartCommand = new RelayCommand(async _ => await StartAsync(), _ => !IsBusy);
CancelCommand = new RelayCommand(_ => _cts?.Cancel(), _ => IsBusy);
}
private async Task StartAsync()
{
IsBusy = true;
_cts = new CancellationTokenSource();
var progress = new Progress<double>(value => Progress = value * 100);
try
{
Status = "Processing...";
await FakeWorkAsync(progress, _cts.Token);
Status = "Completed";
}
catch (OperationCanceledException)
{
Status = "Canceled";
}
catch (Exception ex)
{
Status = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
_cts = null;
}
}
private static async Task FakeWorkAsync(IProgress<double> progress, CancellationToken ct)
{
const int total = 1000;
await Task.Run(async () =>
{
for (int i = 0; i < total; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(2, ct).ConfigureAwait(false);
progress.Report((i + 1) / (double)total);
}
}, ct);
}
}
Task.Run offloads CPU work to the thread pool; ConfigureAwait(false) keeps the inner loop on the background thread. Progress<T> marshals results back to UI thread automatically.
<StackPanel Spacing="12">
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" IsIndeterminate="{Binding IsBusy}"/>
<TextBlock Text="{Binding Status}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Start" Command="{Binding StartCommand}"/>
<Button Content="Cancel" Command="{Binding CancelCommand}"/>
</StackPanel>
</StackPanel>
Reuse HttpClient (per host/service) to avoid socket exhaustion. Inject or hold static instance.
public static class ApiClient
{
public static HttpClient Instance { get; } = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
}
public async Task<T?> GetJsonAsync<T>(string url, CancellationToken ct)
{
using var resp = await ApiClient.Instance.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
return await JsonSerializer.DeserializeAsync<T>(stream, cancellationToken: ct);
}
public async Task PostWithRetryAsync<T>(string url, T payload, CancellationToken ct)
{
var policy = Policy
.Handle<HttpRequestException>()
.Or<TaskCanceledException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))); // exponential backoff
await policy.ExecuteAsync(async token =>
{
using var response = await ApiClient.Instance.PostAsJsonAsync(url, payload, token);
response.EnsureSuccessStatusCode();
}, ct);
}
Use Polly or custom retry logic. Timeouts and cancellation tokens help stop hanging requests.
public async Task DownloadAsync(Uri uri, IStorageFile destination, IProgress<double> progress, CancellationToken ct)
{
using var response = await ApiClient.Instance.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength;
await using var httpStream = await response.Content.ReadAsStreamAsync(ct);
await using var fileStream = await destination.OpenWriteAsync();
var buffer = new byte[81920];
long totalRead = 0;
int read;
while ((read = await httpStream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, read), ct);
totalRead += read;
if (contentLength.HasValue)
progress.Report(totalRead / (double)contentLength.Value);
}
}
Avalonia doesn't ship built-in connectivity events; rely on platform APIs or ping endpoints.
System.Net.NetworkInformation.NetworkChange events.navigator.onLine via JS interop.Expose a service to signal connectivity changes to view models; keep offline caching in mind.
public interface INetworkStatusService
{
IObservable<bool> ConnectivityChanges { get; }
}
public sealed class NetworkStatusService : INetworkStatusService
{
public IObservable<bool> ConnectivityChanges { get; }
public NetworkStatusService()
{
ConnectivityChanges = Observable
.FromEventPattern<NetworkAvailabilityChangedEventHandler, NetworkAvailabilityEventArgs>(
handler => NetworkChange.NetworkAvailabilityChanged += handler,
handler => NetworkChange.NetworkAvailabilityChanged -= handler)
.Select(args => args.EventArgs.IsAvailable)
.StartWith(NetworkInterface.GetIsNetworkAvailable());
}
}
Register different implementations per target in DI (#if or platform-specific partial classes). On mobile, back the observable with platform connectivity APIs; on WebAssembly, bridge to navigator.onLine via JS interop. View models can subscribe once and stay platform-agnostic.
For periodic tasks, use DispatcherTimer on UI thread or Task.Run loops with delays.
var timer = new DispatcherTimer(TimeSpan.FromMinutes(5), DispatcherPriority.Background, (_, _) => RefreshCommand.Execute(null));
timer.Start();
Long-running background work should check CancellationToken frequently, especially when app might suspend (mobile).
For cross-platform apps, wrap periodic or startup work in services that plug into each lifetime. Example using IHostedService semantics:
public interface IBackgroundTask
{
Task StartAsync(CancellationToken token);
Task StopAsync(CancellationToken token);
}
public sealed class SyncBackgroundTask : IBackgroundTask
{
private readonly IDataSync _sync;
public SyncBackgroundTask(IDataSync sync) => _sync = sync;
public Task StartAsync(CancellationToken token)
=> Task.Run(() => _sync.RunLoopAsync(token), token);
public Task StopAsync(CancellationToken token)
=> _sync.StopAsync(token);
}
public static class BackgroundTaskExtensions
{
public static void Attach(this IBackgroundTask task, IApplicationLifetime lifetime)
{
switch (lifetime)
{
case IClassicDesktopStyleApplicationLifetime desktop:
desktop.Startup += async (_, _) => await task.StartAsync(CancellationToken.None);
desktop.Exit += async (_, _) => await task.StopAsync(CancellationToken.None);
break;
case ISingleViewApplicationLifetime singleView when singleView.MainView is { } view:
view.AttachedToVisualTree += async (_, _) => await task.StartAsync(CancellationToken.None);
view.DetachedFromVisualTree += async (_, _) => await task.StopAsync(CancellationToken.None);
break;
}
}
}
Desktop lifetimes expose Startup/Exit; single-view/mobile lifetimes expose FrameworkInitializationCompleted/OnStopped. Provide adapters per lifetime so the task implementation stays portable, and inject platform helpers (connectivity, storage) through interfaces.
Observable.FromEventPattern converts callbacks into composable streams. Combine it with DispatcherScheduler.Current (from System.Reactive) so observations switch back to the UI thread.
var pointerStream = Observable
.FromEventPattern<PointerEventArgs>(handler => control.PointerMoved += handler,
handler => control.PointerMoved -= handler)
.Select(args => args.EventArgs.GetPosition(control))
.Throttle(TimeSpan.FromMilliseconds(50))
.ObserveOn(DispatcherScheduler.Current)
.Subscribe(point => PointerPosition = point);
Disposables.Add(pointerStream);
This pattern keeps heavy processing (Throttle, network calls) off the UI thread while delivering results back in order. For view models, expose IObservable<T> properties and let the view subscribe using ReactiveUI.WhenAnyValue or manual subscriptions.
Disposables here is a CompositeDisposable that you dispose when the view/control unloads.
Use Task.Delay injection or ITestScheduler (ReactiveUI) to control time. For plain async code, wrap delays in an interface to mock in tests.
public interface IDelayProvider
{
Task Delay(TimeSpan time, CancellationToken ct);
}
public sealed class DelayProvider : IDelayProvider
{
public Task Delay(TimeSpan time, CancellationToken ct) => Task.Delay(time, ct);
}
Inject and replace with deterministic delays in tests.
ClientWebSocket when allowed by browser.await Task.Yield()) to avoid blocking JS event loop.IProgress<double>.Observable pipeline with throttling and verify updates stay on the UI thread.Dispatcher.csDispatcherPriority.cs, DispatcherTimer.csIClassicDesktopStyleApplicationLifetime, ISingleViewApplicationLifetimeProgress<T>Dispatcher.UIThread manually?What's next