WinForms Async APIs (.NET 9/10)
When to use this skill
Use this skill when generating or reviewing WinForms code that:
- Marshals work to the UI thread from a worker thread or
Task.Run. - Updates controls, reads control state, or runs async UI-bound operations.
- Shows forms or dialogs asynchronously, especially in multi-form, MVVM, or DI scenarios.
- Triggers async work from a synchronous context (event handlers,
OnLoad). - Needs to defer work until after the message queue drains (post-show kickoff, reacting after a Windows message completes).
- Hits
InvokeAsyncoverload-resolution errors/warnings (e.g.WFO2001).
Do not reach for these APIs to replace plain await of background work that
never touches the UI — that needs no marshalling.
The APIs at a glance
| API | Status | Purpose |
|---|---|---|
Control.InvokeAsync |
Stable (.NET 9) | Marshal sync/async callbacks to the UI thread, non-blocking. |
Form.ShowAsync |
Stable (.NET 10) | Show a modeless form asynchronously. |
Form.ShowDialogAsync |
Stable (.NET 10) | Show a modal dialog asynchronously. |
TaskDialog.ShowDialogAsync |
Stable (.NET 10) | Show a Task Dialog asynchronously. |
The form/dialog APIs were experimental in .NET 9 (required suppressing
WFO5002). As of .NET 10 they are stable — WFO5002 is no longer raised, and no
opt-in is needed.
Control.InvokeAsync
InvokeAsync posts the delegate to the WinForms message queue and returns
immediately — the calling thread is not blocked. Contrast with Control.Invoke,
which sends the delegate and blocks until the UI thread finishes it.
| Operation | Method | Blocking |
|---|---|---|
| Send | Control.Invoke |
Yes — waits for completion. |
| Post | Control.InvokeAsync |
No — queues and returns. |
Posting keeps the message loop free to repaint, handle clicks, and process input, so the UI stays responsive even under many UI-bound tasks.
Overloads
public async Task InvokeAsync(Action callback, CancellationToken cancellationToken = default)
public async Task<T> InvokeAsync<T>(Func<T> callback, CancellationToken cancellationToken = default)
public async Task InvokeAsync(Func<CancellationToken, ValueTask> callback, CancellationToken cancellationToken = default)
public async Task<T> InvokeAsync<T>(Func<CancellationToken, ValueTask<T>> callback, CancellationToken cancellationToken = default)
Choosing the right overload
- Sync, no return value →
Action. - Sync, returns
T→Func<T>. - Async, no result →
Func<CancellationToken, ValueTask>. - Async, returns
T→Func<CancellationToken, ValueTask<T>>.
The two async overloads receive a CancellationToken and return a ValueTask,
which InvokeAsync awaits internally.
Examples
// Sync, no return value.
await control.InvokeAsync(() => control.Text = "Updated Text");
// Sync, returns a value.
int itemCount = await control.InvokeAsync(() => comboBox.Items.Count);
// Async, no result.
await control.InvokeAsync(async (ct) =>
{
await Task.Delay(1000, ct);
control.Text = "Data Loaded";
});
// Async, returns a value.
int count = await control.InvokeAsync(async (ct) =>
{
await Task.Delay(500, ct);
return comboBox.Items.Count;
});
Avoiding accidental fire-and-forget
Passing an async (Task-returning) method to a synchronous overload without a
CancellationToken produces a fire-and-forget call that cannot be awaited
internally. The WinForms analyzer flags this:
warning WFO2001: Task is being passed to InvokeAsync without a cancellation token.
When generating code, ensure async callbacks return ValueTask (not Task),
accept a CancellationToken, and resolve to the async overload.
Overload resolution with Task.Run
InvokeAsync returns a Task — that Task cannot be passed to Task.Run,
which needs an Action or a Func<Task>. Wrap the call in a local function:
// Local function -> calls the loop on the UI thread.
Task InvokeTask() => this.InvokeAsync(ActualDisplayLoopAsync, CancellationToken.None);
await Task.Run(InvokeTask);
async ValueTask ActualDisplayLoopAsync(CancellationToken cancellation = default)
{
// ...
}
Deferred execution: posting work behind the message queue
InvokeAsync isn't only for cross-thread marshalling. Calling it from code that
is already on the UI thread is a legitimate pattern: the delegate is posted
behind all currently queued messages, so it runs only after the message loop
drains what's pending.
Letting a Windows message finish before reacting to it. Inside a KeyDown
handler, calling SelectAll directly can be defeated by the still-in-flight key
message. Posting it sidesteps that — and works whether or not e.Handled was set:
private async void TextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode is Keys.Down)
{
_textBox.Text = "Some text to pick from";
await InvokeAsync(() => _textBox.SelectAll());
}
}
Kicking off work once the form is fully shown. As the last line of OnLoad,
a posted delegate runs after the framework's own show/activate/paint messages
drain — i.e. once the form is not just constructed but visible and active. It
keeps OnLoad synchronous (no async void on the override) and the kickoff
non-blocking, with its own try/catch:
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
// Synchronous infra setup runs here and completes before OnLoad returns.
// Posted last: runs after the form is shown and the message queue drains.
_ = InvokeAsync(StartUpWorkAsync, CancellationToken.None);
}
Note: this defers the kickoff past the framework's remaining init/paint
messages — not past your own OnLoad code, which has already run synchronously
by this point.
Form and dialog async APIs
Form.ShowAsync and Form.ShowDialogAsync show forms asynchronously without
blocking the UI thread — handy when juggling multiple instances of the same
form type (e.g. one window per document). ShowAsync returns a plain Task
that completes when the form is closed or disposed; it returns immediately even
for a large, slow-to-initialize form.
As of .NET 10 the async task's state machine holds only a weak reference to the form (not a strong one), so a long-lived task does not keep the form alive and form lifetime stays decoupled from how long the caller retains the task.
MyForm myForm = new();
await myForm.ShowAsync();
DialogResult result = await myForm.ShowDialogAsync();
if (result is DialogResult.OK)
{
// Act on the dialog result.
}
TaskDialog.ShowDialogAsync shows a Task Dialog asynchronously:
TaskDialogPage taskDialogPage = new()
{
Heading = "Processing...",
Text = "Please wait while we complete the task."
};
TaskDialogButton buttonClicked = await TaskDialog.ShowDialogAsync(taskDialogPage);
Notes: implausible calls are expected to throw (e.g. calling ShowAsync twice on
the same instance). Forms are shown on the UI thread.
Starting async work from synchronous code
Avoid async void — the caller cannot await or observe completion, and
exceptions escape normal Task error handling.
Exception: event handlers (and methods with event-handler signatures) cannot
return Task, so async void is unavoidable there. Wrap the awaited body in
try/catch so exceptions are still handled:
private async void Button_Click(object sender, EventArgs e)
{
try
{
await PerformLongRunningOperationAsync();
}
catch (Exception ex)
{
MessageBox.Show(
$"An error occurred: {ex.Message}",
"Error",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
}
}
Kicking an async loop off from OnLoad is the standard pattern. OnLoad
completes at the first await; the message loop stays free, and the runtime
resumes the method after each awaited task — so an infinite async loop does not
freeze the UI:
protected override async void OnLoad(EventArgs e)
{
base.OnLoad(e);
await RunDisplayLoopAsync();
}
This is cooperative, not parallel — like a relay race passing a baton — until the
loop is explicitly moved onto another thread via Task.Run. At that point any UI
access inside it must go through InvokeAsync, or a cross-thread exception is
thrown.
Parallelizing UI-bound work
To run two UI-bound operations concurrently inside a loop, start both, await
Task.WhenAny so the faster one isn't blocked by the slower, then reset the
completed task to null so the next iteration restarts it. Use a
CancellationTokenSource cancelled in OnFormClosing to break the loop cleanly:
private async Task RunDisplayLoopAsync()
{
Task? uiUpdateTask = null;
Task? separatorFadingTask = null;
while (true)
{
async Task FadeInFadeOutAsync(CancellationToken cancellation)
{
await _sevenSegmentTimer.FadeSeparatorsInAsync(cancellation).ConfigureAwait(false);
await _sevenSegmentTimer.FadeSeparatorsOutAsync(cancellation).ConfigureAwait(false);
}
uiUpdateTask ??= _sevenSegmentTimer.UpdateTimeAndDelayAsync(
time: TimeOnly.FromDateTime(DateTime.Now),
cancellation: _formCloseCancellation.Token);
separatorFadingTask ??= FadeInFadeOutAsync(_formCloseCancellation.Token);
Task completed = await Task.WhenAny(separatorFadingTask, uiUpdateTask);
if (completed.IsCanceled)
{
break;
}
if (completed == uiUpdateTask)
{
uiUpdateTask = null;
}
else
{
separatorFadingTask = null;
}
}
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
base.OnFormClosing(e);
_formCloseCancellation.Cancel();
}
Use ConfigureAwait(false) for operations that are safe from any thread (e.g.
setting a label color); skip it where the continuation must stay on the UI thread.
Quick checklist
- Marshalling to the UI thread, non-blocking →
InvokeAsync, notInvoke. - Match the overload to sync/async and return-value needs.
- Async callbacks: return
ValueTask, take aCancellationToken— avoidWFO2001. - Don't pass an
InvokeAsyncTaskintoTask.Run; wrap it in a local function. async voidonly for event handlers, always withtry/catch.- To run work after the message queue drains, post it with
InvokeAsyncfrom the UI thread (e.g. last line ofOnLoad) — keepsOnLoadsync and non-blocking. - Form/dialog async APIs are stable in .NET 10 (no
WFO5002suppression needed).
Related Skills
- WinForms Development
- WinForms Designer Code
- WinForms Data Binding
- WinForms MVVM
- WinForms High-DPI Fluent Layout
Sample code: https://github.com/microsoft/winforms-designer-extensibility/tree/main/Samples/NET%209/Async%20in%20NET%209