Async and UI-thread toolkit for Windows Forms. WinFlow keeps the UI thread responsive by giving you the right primitives for the job: safe marshalling, re-entrancy-safe async commands, throttled progress, debounce/throttle, and batched control updates.
Targets .NET Framework 4.6.2 and .NET 8 (Windows) from a single package.
Most "WinForms is slow" problems are not the framework, they are:
- Blocking the single UI thread with long work (the UI freezes).
- Updating controls one item at a time (thousands of repaints).
- Spamming
Control.Invokefrom worker threads (the message pump floods).
WinFlow gives you small, focused tools for each of these so you stop re-implementing them in every project.
dotnet add package WinFlow
Capture a dispatcher once on the UI thread (e.g. in your main form constructor):
public partial class MainForm : Form
{
private readonly UiDispatcher _ui;
public MainForm()
{
InitializeComponent();
_ui = UiDispatcher.Capture(); // call on the UI thread
}
}Pick the pattern by workload:
// I/O-bound: just await the native async API. Do NOT wrap in Task.Run.
var data = await httpClient.GetStringAsync(url);
// CPU-bound: push it to the thread pool.
var result = await BackgroundWork.RunCpuBound(ct => HeavyCompute(input, ct));
gridView.DataSource = result; // back on the UI thread automaticallyAsyncCommand ignores repeat triggers while running, supports cancellation,
and raises CanExecuteChanged so the UI can react. ButtonBinder wires it all
to a button, including optional busy text and a cancel button.
var command = new AsyncCommand(async token =>
{
var rows = await LoadFromDbAsync(token);
_ui.Post(() => grid.DataSource = rows);
});
// Auto-disables btnLoad while running, shows "Loading...", enables btnCancel.
ButtonBinder.Bind(btnLoad, command, busyText: "Loading...", cancelButton: btnCancel);ThrottledProgress<T> coalesces high-frequency reports and delivers at most
once per interval on the UI thread, always flushing the final value.
using var progress = new ThrottledProgress<int>(
_ui,
percent => progressBar.Value = percent,
minInterval: TimeSpan.FromMilliseconds(50));
await BackgroundWork.RunCpuBound(ct =>
{
for (int i = 0; i <= total; i++)
{
DoWork(i);
((IProgress<int>)progress).Report(i * 100 / total); // safe to call every iteration
}
});
progress.Flush(); // guarantee the bar reaches 100%private readonly Debouncer _searchDebounce;
// in constructor:
_searchDebounce = new Debouncer(_ui, TimeSpan.FromMilliseconds(300));
private void txtSearch_TextChanged(object sender, EventArgs e)
{
string query = txtSearch.Text;
_searchDebounce.Invoke(() => RunSearch(query)); // fires once user pauses
}Throttler is the leading-edge counterpart for resize/scroll handlers.
// BeginUpdate/EndUpdate + SuspendLayout/ResumeLayout in one call:
listView.BatchUpdate(lv =>
{
foreach (var item in items)
lv.Items.Add(new ListViewItem(item.Name));
});
// Or add a range in a single pass:
listBox.AddRangeBatched(names);
// Run async work with a control disabled (released even on error):
await btnSave.RunDisabledAsync(() => SaveAsync());AsyncDebouncer<TInput, TResult> is the full solution for async search-as-you-type.
Beyond debouncing, it cancels the previous in-flight request when a new one starts
and guarantees a slow earlier request can never overwrite a newer result:
private readonly AsyncDebouncer<string, IReadOnlyList<Product>> _search;
// in constructor:
_search = new AsyncDebouncer<string, IReadOnlyList<Product>>(
_ui,
TimeSpan.FromMilliseconds(300),
operation: (query, token) => _api.SearchAsync(query, token), // gets cancelled if superseded
onResult: results => grid.DataSource = results, // only the latest result lands
onError: ex => statusLabel.Text = ex.Message);
private void txtSearch_TextChanged(object sender, EventArgs e)
=> _search.Invoke(txtSearch.Text);VirtualGridBinding<T> drives a DataGridView in virtual mode. Binding 200,000+
rows is instant because no per-row objects are created; only visible cells are
requested:
var binding = VirtualGridBinding<Product>.Attach(
grid,
new VirtualColumn<Product>("Id", p => p.Id),
new VirtualColumn<Product>("Name", p => p.Name),
new VirtualColumn<Product>("Price", p => p.Price.ToString("C")));
binding.SetData(products); // 200k rows, O(1) — grid only re-queries visible cells// Don't lose exceptions from un-awaited tasks:
RefreshCacheAsync().SafeFireAndForget(ex => logger.Error(ex));
// Bound an operation and cancel it on timeout:
using var cts = new CancellationTokenSource();
var data = await _api.GetAsync(cts.Token).WithTimeout(TimeSpan.FromSeconds(5), cts);dotnet run --project samples/WinFlow.Demo
A four-tab WinForms app demonstrating every feature. The title bar shows a live clock that keeps ticking during all operations, proving the UI thread is never blocked.
| Type | Purpose |
|---|---|
UiDispatcher |
Marshal work to the UI thread (Post, Send, InvokeAsync). Context-based, survives handle recreation. |
AsyncCommand |
Re-entrancy-safe async operation with cancellation and error events. |
ButtonBinder |
Binds an AsyncCommand to a button: auto-disable, busy text, cancel button. |
ThrottledProgress<T> |
Coalesced, rate-limited IProgress<T> delivered on the UI thread. |
Debouncer |
Fire once after a quiet period (search-as-you-type). |
Throttler |
Fire at most once per interval (resize/scroll). |
AsyncDebouncer<TIn,TOut> |
Debounced async search with auto-cancel of stale requests and out-of-order result protection. |
VirtualGridBinding<T> |
Drives DataGridView in virtual mode for hundreds of thousands of rows without per-row objects. |
ControlExtensions |
BatchUpdate, AddRangeBatched, RunDisabledAsync. |
BackgroundWork |
RunCpuBound for thread-pool work (with cancellation). |
TaskExtensions |
SafeFireAndForget, WithTimeout. |
- Context-based marshalling.
UiDispatcherroutes through the capturedSynchronizationContext, not a specific control handle, so it keeps working across handle recreation and does not need a live control reference. - Inline fast-path. When already on the UI thread,
Post/Send/InvokeAsyncrun inline to avoid a needless message round-trip. - No silent swallowing.
AsyncCommandrethrows on the calling context if you don't subscribe toError; cancellation is treated as a normal outcome. - Stale-result protection.
AsyncDebouncerstamps each request with a generation number and re-checks it both before and after marshalling to the UI thread, so an older slow request can never overwrite a newer one.
dotnet build
dotnet test
The library multi-targets net462 and net8.0-windows. Tests run on
net8.0-windows using a simulated single-threaded UI synchronization context.
MIT. See LICENSE.