When working with Blazor WebAssembly, JavaScript interop often feels straightforward—until it isn’t. One of the more subtle (and frustrating) issues I’ve run into involves Safari, user-initiated navigation, and async event handlers.
This post documents a real-world lesson learned: why I intentionally avoid async when triggering browser navigation (like mailto:) from Blazor, even though async/await is usually considered best practice.
The Scenario
I had a simple requirement:
- A MudBlazor button
- Track a telemetry event (Application Insights)
- Open the user’s email client using
mailto:
The Blazor component looked roughly like this:
<MudButton OnClick="OnClick">
Contact Us
</MudButton>
And the click handler:
private void OnClick(MouseEventArgs _)
{
Telemetry.TrackEmailClick(Source, Page ?? "unknown");
JS.InvokeVoidAsync("open", "mailto:sales@tanolis.us", "_self");
}
No await. No async.
This worked consistently across Chrome, Edge, and Firefox—and, crucially, Safari.
The Temptation: “Make It Async”
From a .NET perspective, the “correct” version might look like this:
private async Task OnClick(MouseEventArgs _)
{
Telemetry.TrackEmailClick(Source, Page ?? "unknown");
await JS.InvokeVoidAsync("open", "mailto:sales@tanolis.us", "_self");
}
This compiles. It looks clean. It follows modern C# guidance.
And on Safari… it starts failing.
What Goes Wrong on Safari
Safari (especially on iOS and embedded WebViews) has long-standing quirks with:
asyncevent handlers- Microtask scheduling
- JavaScript → native navigation handoffs
- WASM + browser event loop interaction
The failure mode is subtle:
- User clicks the button
- Telemetry call runs
awaityields control- Browser decides the click is no longer a “direct user gesture”
- Navigation (
mailto:/window.open) is silently ignored
No exception. No console error. Just… nothing happens.
This is not theoretical—it’s reproducible and widely encountered in real apps.
The Key Rule I Now Follow
In Blazor + Safari, never
awaitbefore triggering browser navigation.
That includes:
await JS.InvokeVoidAsync(...)await fetch(...)- Any Promise-based wrapper
- Even
await Task.Yield()
If navigation depends on the click being considered a user gesture, you must not yield execution.
Why Fire-and-Forget Is Correct Here
In this scenario:
- I don’t need a return value
- I don’t need to handle JS errors
- The action is terminal (navigation)
- Telemetry is best-effort
So fire-and-forget is not a shortcut—it’s the correct design.
private void OnClick(MouseEventArgs _)
{
Telemetry.TrackEmailClick(Source, Page ?? "unknown");
JS.InvokeVoidAsync("open", "mailto:sales@tanolis.us", "_self");
}
This keeps:
- The handler synchronous
- Safari happy
- User behavior consistent across browsers
A Small Safari-Friendly Improvement
Safari is more reliable with location.href than window.open.
JavaScript helper:
window.openMail = (email) => {
window.location.href = `mailto:${email}`;
};
Blazor call:
JS.InvokeVoidAsync("openMail", "sales@tanolis.us");
Still synchronous. Still safe.
What I Avoid on Purpose
These patterns have caused real issues for me on Safari:
async void OnClick(...)await JS.InvokeVoidAsync(...)before navigation- Promise chains in click handlers
- Telemetry calls after navigation
- Over-engineering interop for simple user actions
Sometimes the simplest code is the most correct.
The Bigger Lesson
“Best practice” depends on context.
In a browser + WASM + JS interop + Safari environment, purity gives way to pragmatism. Understanding how browsers interpret user gestures matters more than following async patterns blindly.
This is one of those cases where experience beats theory.
Final Takeaway
If you’re triggering navigation from Blazor WebAssembly:
- Keep the event handler synchronous
- Fire telemetry first
- Trigger navigation immediately
- Do not
await - Especially if Safari matters
Your future self (and your users) will thank you.

Add to favorites
Leave a Reply
You must be logged in to post a comment.