Preventing Duplicate Async Operations in .NET MAUI
π¦ Preventing Duplicate Async Operations in .NET MAUI
Modern mobile applications are asynchronous by nature. A user taps a button and an HTTP request starts. A page appears and data begins loading. Pull-to-refresh triggers synchronization. Navigation starts while another operation is still running. A search box fires multiple requests as the user types. Most of the time, everything works. Until the same operation starts twice. A user double-taps Save.
Two HTTP requests are sent. Two database records are created. Two navigation operations are pushed onto the stack. Two loading indicators compete with each other. Or, even worse, two asynchronous operations modify the same application state simultaneously.
These bugs are surprisingly common in .NET MAUI applications because async makes concurrency easy to introduceβeven when concurrency was never intended. In this article, we'll explore how duplicate asynchronous operations happen in .NET MAUI, why a simple IsBusy flag isn't always enough, and how to build reusable patterns for preventing accidental concurrent execution. π
π Table of Contents
- Understanding the Problem
- How Duplicate Operations Happen
- Why
asyncChanges the Execution Model - The Classic
IsBusyApproach - Why
IsBusyCan Fail - Using
SemaphoreSlim - Creating a Reusable Async Execution Guard
- Returning Results from Protected Operations
- Supporting Cancellation
- Preventing Duplicate MVVM Commands
- CommunityToolkit.Mvvm and Async Commands
- Preventing Duplicate Navigation
- Protecting HTTP Requests
- Protecting Database Operations
- Refresh and Synchronization Scenarios
- Latest-Wins Operations
- Queue vs Reject vs Cancel
- Exception Handling
- UI State and Progress
- Dependency Injection
- Testing Concurrent Operations
- Performance Considerations
- Common Mistakes
- Choosing the Right Strategy
- Production Architecture
- Conclusion
- References
1. π Understanding the Problem
Consider a simple button in a .NET MAUI application:
<Button
Text="Save"
Clicked="SaveButton_Clicked" />
And the corresponding handler:
private async void SaveButton_Clicked(object sender, EventArgs e)
{
await SaveAsync();
}
The implementation might perform an API request:
private async Task SaveAsync()
{
await _customerService.SaveAsync(Customer);
}
This looks perfectly reasonable. But what happens if the user taps the button twice quickly? The first call starts:
Tap #1
β
βΌ
SaveAsync()
β
βΌ
HTTP Request #1
Before that operation completes, another tap occurs:
Tap #2
β
βΌ
SaveAsync()
β
βΌ
HTTP Request #2
Now both operations are running concurrently. The application effectively looks like this:
UI Thread
β
βββ SaveAsync #1 βββββββββΊ API
β
βββ SaveAsync #2 βββββββββΊ API
Nothing about async automatically prevents this.
2. β οΈ How Duplicate Operations Happen
Duplicate asynchronous execution can originate from many places.
| Scenario | Possible consequence |
|---|---|
| Double-tapping Save | Duplicate API requests |
| Double-tapping navigation | Duplicate pages |
| Repeated refresh | Concurrent synchronization |
| Multiple lifecycle events | Duplicate initialization |
| Search input | Stale responses |
| Retry logic | Duplicate requests |
| Event subscriptions | Same handler executed multiple times |
| Background + foreground work | Shared-state race |
| Multiple commands | Conflicting mutations |
| Repeated initialization | Duplicate services/resources |
The important distinction is that the operation itself may be perfectly correct.
The problem is execution policy. Should the operation:
- run concurrently?
- reject duplicate execution?
- wait for the current operation?
- cancel the previous operation?
- queue requests?
- merge requests?
- allow only the latest result?
Those are architectural decisions.
3. π§΅ Why async Changes the Execution Model
Consider:
private async Task LoadAsync()
{
var result = await _service.GetDataAsync();
Items = result; } ```
When execution reaches:
```csharp
await _service.GetDataAsync();
the method yields while the asynchronous operation is incomplete. That means another event can trigger another call to LoadAsync(). Conceptually:
LoadAsync #1
β
βββ Start request
β
βββ await
β
β LoadAsync #2
β β
β βββ Start request
β βββ await
β
βΌ
Request #1 completes
Now multiple executions exist.
This is not inherently bad. Concurrency is useful.
The problem occurs when the operation was logically intended to be single-flight.
4. π§ The Classic IsBusy Approach
A common solution is:
private bool _isBusy;
private async Task LoadAsync() { if (_isBusy) return; _isBusy = true; try { await LoadDataAsync(); } finally { _isBusy = false; } } ```
This is dramatically better than doing nothing. The `finally` block is especially important.
Without it:
```csharp
_isBusy = true;
await LoadDataAsync(); _isBusy = false; ``` an exception could leave the operation permanently locked.
Instead:
```csharp
try
{
await LoadDataAsync();
}
finally
{
_isBusy = false;
}
ensures the state is restored.
5. 𧨠Why IsBusy Can Fail
An IsBusy property is excellent for representing UI state. It is not always the best synchronization primitive.
Imagine:
if (_isBusy)
return;
_isBusy = true; ``` These are separate operations.
With execution from multiple threads, there is theoretically a window between checking and assigning. Two callers could observe:
```text
Caller A: _isBusy == false
Caller B: _isBusy == false
before either successfully establishes ownership. For UI events constrained to the UI thread, this may not appear frequently. But once the same operation can be called from:
- background services
- lifecycle callbacks
- timers
- messaging
- multiple commands
- synchronization workers
The assumptions become weaker.
The better design is to separate:
UI State
from:
Concurrency Control
For example:
IsBusy
β
βββ UI representation
SemaphoreSlim
β
βββ execution synchronization
6. π Using SemaphoreSlim
.NET provides SemaphoreSlim, which works well with asynchronous code. For a single-operation gate:
private readonly SemaphoreSlim _semaphore = new(1, 1);
The first 1 represents the initial count. The second represents the maximum count. Effectively:
Maximum concurrent operations = 1
Then:
private async Task SaveAsync()
{
await _semaphore.WaitAsync();
try { await _customerService.SaveAsync(Customer); } finally { _semaphore.Release(); } } ```
Now concurrent callers wait.
```text
Caller A
β
βΌ
Acquire
β
βΌ
Execute
β
βΌ
Release
β
βΌ
Caller B
This solves mutual exclusion.
However, there is an important question: Do we actually want Caller B to wait? For many UI actions, probably not.
7. π¦ Rejecting Duplicate Operations
Suppose Save is already running. The user taps Save again.
Instead of queueing another save, we can simply reject it. SemaphoreSlim supports this pattern:
if (!await _semaphore.WaitAsync(0))
return;
The 0 means:
Attempt to acquire immediately. If unavailable, don't wait.
Complete example:
private readonly SemaphoreSlim _saveGate = new(1, 1);
private async Task SaveAsync() { if (!await _saveGate.WaitAsync(0)) return; try { await _customerService.SaveAsync(Customer); } finally { _saveGate.Release(); } } ```
Now:
```text
Tap #1
β
βββ Acquire β
β
βββ Save
Tap #2
β
βββ Acquire β
βββ ignored
For many mobile UI operations, this is exactly what we want.
8. π§© Creating a Reusable Async Execution Guard
Repeating SemaphoreSlim everywhere quickly becomes noisy. We can encapsulate the pattern.
public sealed class AsyncExecutionGuard : IDisposable
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<bool> TryExecuteAsync( Func<Task> operation, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(operation); if (!await _semaphore.WaitAsync(0, cancellationToken)) return false; try { await operation(); return true; } finally { _semaphore.Release(); } } public void Dispose() { _semaphore.Dispose(); } } ``` Usage:
```csharp
private readonly AsyncExecutionGuard _saveGuard = new();
private async Task SaveAsync() { await _saveGuard.TryExecuteAsync(async () => { await _customerService.SaveAsync(Customer); }); } ```
This makes the intent obvious:
```text
Try to execute this operation once.
If another execution is active, reject the duplicate.
9. π¦ Returning Results from Protected Operations
Real operations usually return values. We can introduce a result wrapper:
public readonly record struct GuardedExecutionResult<T>(
bool Executed,
T? Value);
Then:
public sealed class AsyncExecutionGuard : IDisposable
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<GuardedExecutionResult<T>> TryExecuteAsync<T>( Func<Task<T>> operation, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(operation); if (!await _semaphore.WaitAsync(0, cancellationToken)) { return new GuardedExecutionResult<T>( false, default); } try { var value = await operation(); return new GuardedExecutionResult<T>( true, value); } finally { _semaphore.Release(); } } public void Dispose() { _semaphore.Dispose(); } } ``` Usage:
```csharp
var result = await _loadGuard.TryExecuteAsync(
() => _customerService.GetCustomersAsync());
if (!result.Executed) return; Customers = result.Value; ``` This avoids confusing "not executed" with a legitimate `null` result.
---
# 10. π Supporting Cancellation
Production asynchronous APIs should usually accept a `CancellationToken`.
For example:
```csharp
public async Task<bool> TryExecuteAsync(
Func<CancellationToken, Task> operation,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(operation);
if (!await _semaphore.WaitAsync(0, cancellationToken)) return false; try { await operation(cancellationToken); return true; } finally { _semaphore.Release(); } } ``` Usage:
```csharp
await _saveGuard.TryExecuteAsync(
async cancellationToken =>
{
await _customerService.SaveAsync(
Customer,
cancellationToken);
},
cancellationToken);
Now cancellation flows through the entire operation.
ViewModel
β
βΌ
Guard
β
βΌ
Service
β
βΌ
HttpClient
This is much better than checking cancellation only at the UI layer.
11. π§ Preventing Duplicate MVVM Commands
Consider:
public ICommand SaveCommand { get; }
With:
SaveCommand = new Command(
async () => await SaveAsync());
Repeated command execution can still happen. A basic ViewModel approach is:
private bool _isSaving;
private async Task SaveAsync() { if (_isSaving) return; _isSaving = true; try { await _service.SaveAsync(Model); } finally { _isSaving = false; } } ``` Better still, use both UI state and execution protection:
```csharp
private readonly AsyncExecutionGuard _saveGuard = new();
private bool _isSaving; public bool IsSaving { get => _isSaving; set { _isSaving = value; OnPropertyChanged(); } } ``` Then:
```csharp
private async Task SaveAsync()
{
await _saveGuard.TryExecuteAsync(async () =>
{
IsSaving = true;
try { await _service.SaveAsync(Model); } finally { IsSaving = false; } }); } ``` Here each mechanism has one responsibility.
| Mechanism | Responsibility |
|---|---|
| `AsyncExecutionGuard` | Concurrency |
| `IsSaving` | UI state |
| Command | User intent |
| Service | Business operation |
This separation scales much better.
---
# 12. π§° CommunityToolkit.Mvvm and Async Commands
Many .NET MAUI applications use `CommunityToolkit.Mvvm`. A ViewModel might use:
```csharp
[RelayCommand]
private async Task SaveAsync()
{
await _service.SaveAsync(Model);
}
For asynchronous commands, command execution semantics matter. A key architectural question remains:
Should concurrent executions be allowed?
Even when your command framework protects against some duplicate command scenarios, synchronization may still belong deeper in the application if the same operation is callable from multiple entry points.
For example:
SaveCommand βββββββ
β
AutoSave ββββββββββΌβββΊ SaveService
β
Lifecycle Save ββββ
Protecting only the button does not necessarily protect the underlying business operation. This distinction becomes important in larger applications.
13. π§ Preventing Duplicate Navigation
Navigation is one of the most visible duplicate-operation bugs.
Consider:
private async Task OpenDetailsAsync()
{
await Shell.Current.GoToAsync("details");
}
Double-tap:
Tap
βββ details
Tap
βββ details
The navigation stack might become:
Home
β
Details
β
Details
A dedicated navigation gate can prevent this:
private readonly AsyncExecutionGuard _navigationGuard = new();
private async Task OpenDetailsAsync() { await _navigationGuard.TryExecuteAsync(async () => { await Shell.Current.GoToAsync("details"); }); } ```
Now only one navigation operation runs at a time.
---
# 14. ποΈ Centralizing Navigation Protection
If navigation happens throughout the application, protecting each ViewModel independently may not be enough.
Consider a navigation service:
```csharp
public interface INavigationService
{
Task NavigateAsync(
string route,
CancellationToken cancellationToken = default);
}
Implementation:
public sealed class NavigationService : INavigationService
{
private readonly SemaphoreSlim _navigationGate = new(1, 1);
public async Task NavigateAsync( string route, CancellationToken cancellationToken = default) { if (!await _navigationGate.WaitAsync( 0, cancellationToken)) { return; } try { await Shell.Current.GoToAsync(route); } finally { _navigationGate.Release(); } } } ``` This protects navigation globally.
```text
Page A ββββββ
β
Page B ββββββΌβββΊ NavigationService βββΊ Shell
β
Page C ββββββ
That is often more robust than implementing separate flags in every page.
15. π Protecting HTTP Requests
Imagine a ViewModel loading account information:
private async Task LoadAccountAsync()
{
Account = await _api.GetAccountAsync();
}
The method might be triggered by:
- page appearing
- pull-to-refresh
- retry
- reconnect
- manual reload
You could accidentally send several identical requests. A guard prevents that:
private readonly AsyncExecutionGuard _loadGuard = new();
private async Task LoadAccountAsync() { await _loadGuard.TryExecuteAsync(async () => { Account = await _api.GetAccountAsync(); }); } ``` But here we should ask another question:
> Should the duplicate caller be ignored, or should it share the existing operation?
Those are different patterns.
---
# 16. βοΈ Single-Flight Operations
Suppose two callers request the same resource simultaneously.
Instead of:
```text
Caller A βββΊ HTTP Request
Caller B βββΊ ignored
we might want:
Caller A βββ
ββββΊ HTTP Request βββΊ Result
Caller B βββ
Both callers await the same operation. A simple implementation:
private readonly object _sync = new();
private Task<Account>? _currentLoad;
public Task<Account> LoadAccountAsync() { lock (_sync) { if (_currentLoad is not null) return _currentLoad; _currentLoad = LoadCoreAsync(); return _currentLoad; } } ``` Then:
```csharp
private async Task<Account> LoadCoreAsync()
{
try
{
return await _api.GetAccountAsync();
}
finally
{
lock (_sync)
{
_currentLoad = null;
}
}
}
This is commonly described as a single-flight pattern. Multiple callers share one in-flight operation.
17. π Refresh Operations
Refresh is another common source of duplicates.
For example:
<RefreshView
IsRefreshing="{Binding IsRefreshing}"
Command="{Binding RefreshCommand}">
If refresh can also be triggered programmatically:
await RefreshAsync();
you may have:
Pull-to-refresh
β
ββββββββββββ
βΌ
RefreshAsync
β²
ββββββββββββ
β
Reconnect event
A guard makes the behavior deterministic.
private readonly AsyncExecutionGuard _refreshGuard = new();
private async Task RefreshAsync() { await _refreshGuard.TryExecuteAsync(async () => { IsRefreshing = true; try { await ReloadDataAsync(); } finally { IsRefreshing = false; } }); } ```
---
# 18. π Search Is Different
Search deserves special treatment.
Imagine the user types:
```text
M
MA
MAU
MAUI
Each change starts a request:
Search("M")
Search("MA")
Search("MAU")
Search("MAUI")
Rejecting every request after the first would be wrong. The user wants the latest query. For search, a better strategy is often:
Cancel Previous
+
Execute Latest
19. π Latest-Wins with Cancellation
We can keep a CancellationTokenSource:
private CancellationTokenSource? _searchCts;
Then:
private async Task SearchAsync(string query)
{
_searchCts?.Cancel();
_searchCts?.Dispose();
_searchCts = new CancellationTokenSource(); var cancellationToken = _searchCts.Token; try { var results = await _searchService.SearchAsync( query, cancellationToken); SearchResults = results; } catch (OperationCanceledException) { } } ```
Now:
```text
"M"
β
βββ Request A
"MA"
β
βββ Cancel A
βββ Request B
"MAU"
β
βββ Cancel B
βββ Request C
"MAUI"
β
βββ Cancel C
βββ Request D
This matches search semantics much better.
20. β³ Adding Debouncing
Cancellation alone may still produce many requests. A debounce delay can reduce them:
private async Task SearchAsync(string query)
{
_searchCts?.Cancel();
_searchCts?.Dispose();
_searchCts = new CancellationTokenSource(); var token = _searchCts.Token; try { await Task.Delay( TimeSpan.FromMilliseconds(300), token); var results = await _searchService.SearchAsync( query, token); SearchResults = results; } catch (OperationCanceledException) { } } ``` Typing quickly:
```text
M ββ cancel
MA ββ cancel
MAU ββ cancel
MAUI ββ 300ms βββΊ Search
Only the final query reaches the server.
21. π§ Reject, Wait, Cancel, Queue, or Share?
This is the central design decision. There is no universal duplicate-operation strategy.
| Strategy | Behavior | Good use case |
|---|---|---|
| Reject | Ignore duplicate | Save button |
| Wait | Execute after current | Serialized writes |
| Cancel previous | Latest operation wins | Search |
| Queue | Preserve every operation | Upload jobs |
| Share | Callers await same task | Data initialization |
| Allow | Run concurrently | Independent requests |
Choosing the wrong strategy can be as problematic as having no synchronization.
22. πΎ Protecting Database Writes
Suppose a local SQLite repository performs:
public async Task SaveSettingsAsync(Settings settings)
{
await _database.UpdateAsync(settings);
}
Multiple ViewModels could update the same record. A serialization gate can help:
private readonly SemaphoreSlim _writeGate = new(1, 1);
public async Task SaveSettingsAsync( Settings settings, CancellationToken cancellationToken = default) { await _writeGate.WaitAsync(cancellationToken); try { await _database.UpdateAsync(settings); } finally { _writeGate.Release(); } } ```
Notice the difference. For Save button duplicate taps we used:
```csharp
WaitAsync(0)
to reject duplicates. For database writes:
WaitAsync(cancellationToken)
may be preferable because each write could represent meaningful state that should eventually be persisted.
23. π§± Per-Resource Locking
A single global lock can become too restrictive.
Imagine editing different customers:
Customer 10
Customer 20
Customer 30
These operations might safely run concurrently. What we really want is:
Same customer
β
serialized
Different customers β parallel ``` Conceptually:
```text
Customer 10 βββΊ Gate 10
Customer 10 βββΊ Gate 10
Customer 20 βββΊ Gate 20
This is keyed synchronization. A simplified approach might use:
ConcurrentDictionary<Guid, SemaphoreSlim>
For example:
private readonly ConcurrentDictionary<Guid, SemaphoreSlim> _locks = new();
private SemaphoreSlim GetLock(Guid id) { return _locks.GetOrAdd( id, _ => new SemaphoreSlim(1, 1)); } ``` Then:
```csharp
public async Task UpdateCustomerAsync(
Customer customer,
CancellationToken cancellationToken = default)
{
var gate = GetLock(customer.Id);
await gate.WaitAsync(cancellationToken); try { await _repository.UpdateAsync(customer); } finally { gate.Release(); } } ```
In production, keyed locks also require a lifecycle/removal strategy so the dictionary does not grow indefinitely.
---
# 24. β οΈ Exception Handling
Concurrency protection must never swallow legitimate failures accidentally. Avoid:
```csharp
try
{
await operation();
}
catch
{
}
Instead, either propagate:
try
{
await operation();
}
finally
{
_semaphore.Release();
}
or handle specific exceptions at the appropriate layer:
try
{
await _service.SaveAsync(Model);
}
catch (HttpRequestException ex)
{
_logger.LogError(
ex,
"Unable to save customer.");
ErrorMessage = "Unable to save your changes."; } ``` The synchronization primitive should generally not become your entire error-handling architecture.
---
# 25. π Always Release in `finally`
This deserves emphasis. Wrong:
```csharp
await _semaphore.WaitAsync();
await OperationAsync(); _semaphore.Release(); ``` If `OperationAsync()` throws:
```text
Semaphore acquired
β
βΌ
Operation throws
β
βΌ
Release never happens
Every future caller can become blocked. Correct:
await _semaphore.WaitAsync();
try { await OperationAsync(); } finally { _semaphore.Release(); } ``` This should be considered mandatory when manually managing semaphore ownership.
---
# 26. π¨ UI State Should Reflect Execution
Preventing duplicate execution is good. Preventing the user from attempting duplicate execution is even better UX.
For example:
```xml
<Button
Text="Save"
Command="{Binding SaveCommand}"
IsEnabled="{Binding IsNotSaving}" />
And:
public bool IsNotSaving => !IsSaving;
While saving:
Save
β
Button disabled
β
Progress shown
β
Operation completes
β
Button enabled
But remember:
UI disabling is a UX mechanism, not a concurrency guarantee.
The underlying operation should still be protected when duplicate execution would cause incorrect behavior.
27. π Progress Indicators
A protected operation often maps naturally to UI progress.
private async Task LoadAsync()
{
await _loadGuard.TryExecuteAsync(async () =>
{
IsBusy = true;
try { Items = await _service.GetItemsAsync(); } finally { IsBusy = false; } }); } ``` XAML:
```xml
<ActivityIndicator
IsRunning="{Binding IsBusy}"
IsVisible="{Binding IsBusy}" />
The concurrency guard answers:
Can this execute?
The UI state answers:
What should the user see?
Keep those responsibilities separate.
28. π§© Dependency Injection
If guards are reusable infrastructure, they can be introduced through DI.
However, lifetime matters.
Consider:
builder.Services.AddTransient<AsyncExecutionGuard>();
If every consumer receives a different instance, they do not synchronize with one another. For operation-specific guards, it may be better to keep them owned by the component:
private readonly AsyncExecutionGuard _saveGuard = new();
For global operations such as application-wide navigation, a singleton service may make more sense:
builder.Services.AddSingleton<INavigationService, NavigationService>();
The synchronization scope should match the operation scope.
| Operation | Possible scope |
|---|---|
| Page-specific refresh | ViewModel |
| Save action | ViewModel/service |
| Global navigation | Singleton navigation service |
| Database writes | Repository |
| Authentication refresh | Authentication service |
| Application initialization | Application service |
29. π Authentication Token Refresh
Token refresh is a perfect example of single-flight synchronization.
Imagine five API requests discover simultaneously that the access token expired.
Without protection:
Request A βββΊ Refresh token
Request B βββΊ Refresh token
Request C βββΊ Refresh token
Request D βββΊ Refresh token
Request E βββΊ Refresh token
This can be disastrous if refresh tokens rotate.
Instead:
Request A βββ
Request B βββ€
Request C βββΌβββΊ ONE refresh operation
Request D βββ€
Request E βββ
Every caller then uses the same refreshed authentication state.
This is one of the strongest real-world arguments for treating duplicate async execution as an architectural concern rather than merely a button-click problem.
30. π Application Initialization
Applications often initialize several services:
await InitializeAsync();
But initialization might accidentally be triggered from:
- startup
- first page
- authentication flow
- resume
- deep link processing
A shared initialization task can ensure the work occurs once:
private readonly object _sync = new();
private Task? _initializationTask;
public Task InitializeAsync() { lock (_sync) { return _initializationTask ??= InitializeCoreAsync(); } } ``` Then:
```csharp
private async Task InitializeCoreAsync()
{
await LoadConfigurationAsync();
await InitializeDatabaseAsync();
await RestoreSessionAsync();
}
All callers share the same initialization operation.
31. π§ͺ Testing Duplicate Execution
Concurrency behavior should be tested explicitly.
Suppose:
var executionCount = 0;
var guard = new AsyncExecutionGuard();
Create a slow operation:
async Task Operation()
{
Interlocked.Increment(ref executionCount);
await Task.Delay(200); } ``` Then invoke concurrently:
```csharp
var tasks = Enumerable
.Range(0, 20)
.Select(_ => guard.TryExecuteAsync(Operation));
await Task.WhenAll(tasks); ``` Finally:
```csharp
Assert.Equal(1, executionCount);
This verifies that only one operation entered the protected region.
32. π§ͺ Testing Rejection Behavior
We can also test the result:
var results = await Task.WhenAll(
Enumerable
.Range(0, 20)
.Select(_ => guard.TryExecuteAsync(Operation)));
Then:
Assert.Equal(
1,
results.Count(x => x));
Expected:
Executed: 1
Rejected: 19
This makes the concurrency contract explicit.
33. π§ͺ Testing Failure Recovery
A particularly important test verifies that exceptions do not permanently lock the guard. First operation:
await Assert.ThrowsAsync<InvalidOperationException>(
() => guard.TryExecuteAsync(() =>
throw new InvalidOperationException()));
Then execute again:
var executed = await guard.TryExecuteAsync(
() => Task.CompletedTask);
Expected:
Assert.True(executed);
This proves finally released the semaphore correctly.
34. π§ͺ Testing Cancellation
Cancellation should also be deterministic.
using var cts = new CancellationTokenSource();
Start:
var task = guard.TryExecuteAsync(
async token =>
{
await Task.Delay(
TimeSpan.FromSeconds(10),
token);
},
cts.Token);
Then:
cts.Cancel();
The operation should observe cancellation without leaving the guard permanently occupied. Concurrency tests are especially valuable because bugs in this area can be extremely timing-dependent during manual testing.
35. β‘ Performance Considerations
SemaphoreSlim is relatively lightweight and designed to support asynchronous waiting. But synchronization still has architectural cost. Overusing global locks can reduce throughput. Bad:
Every operation
β
βΌ
One global semaphore
Now unrelated operations become serialized. Better:
Navigation βββΊ Navigation Gate
Save βββββββββΊ Save Gate
Refresh ββββββΊ Refresh Gate
Database βββββΊ Repository Gate
Synchronize only the resources or operations that actually require synchronization.
36. 𧨠Common Mistake: async void
UI event handlers naturally use async void:
private async void Button_Clicked(
object sender,
EventArgs e)
{
await SaveAsync();
}
That's acceptable for event handlers. But application logic should generally return Task:
private Task SaveAsync()
instead of:
private async void SaveAsync()
Returning Task allows:
- awaiting
- cancellation
- testing
- exception propagation
- synchronization
- composition
Keep async void at the event boundary.
37. 𧨠Common Mistake: Using Only Button State
This is insufficient:
SaveButton.IsEnabled = false;
await SaveAsync(); SaveButton.IsEnabled = true; ``` Why? Because `SaveAsync()` might also be called from:
```text
Keyboard shortcut
Lifecycle event
Another command
Auto-save
Message handler
Background operation
UI state protects a UI interaction. It does not necessarily protect the operation.
38. 𧨠Common Mistake: Forgetting Cancellation Semantics
Suppose:
await _gate.WaitAsync(cancellationToken);
Cancellation can happen while waiting. That means ownership was never acquired.
Therefore, do not blindly release from code that might execute without successful acquisition. A safe pattern is:
await _gate.WaitAsync(cancellationToken);
try { await OperationAsync(cancellationToken); } finally { _gate.Release(); } ```
Once execution enters the `try`, acquisition has succeeded.
---
# 39. 𧨠Common Mistake: Locking Too Much
Avoid holding synchronization across unrelated work.
For example:
```csharp
await _gate.WaitAsync();
try { await LoadDataAsync(); await Task.Delay(1000); await AnimateAsync(); await ShowAlertAsync(); } finally { _gate.Release(); } ``` Ask what actually needs protection. Perhaps only:
```csharp
await LoadDataAsync();
needs serialization. Smaller critical sections generally improve responsiveness and reduce contention.
40. 𧨠Common Mistake: lock with await
You cannot use await directly inside a traditional lock block:
lock (_sync)
{
await SaveAsync();
}
That is not the correct asynchronous synchronization model. For asynchronous critical sections, primitives such as:
SemaphoreSlim
are generally more appropriate. A regular lock can still be useful for very small synchronous state transitions, such as managing a shared Task reference.
41. π Strategy Comparison
Let's compare the major approaches.
| Approach | Thread-safe | Async-friendly | Duplicate behavior | Complexity |
|---|---π---π---|---|
| bool IsBusy | β οΈ Depends | β
| Reject | Low |
| Disable UI | β | β | Prevents UI input only | Low |
| SemaphoreSlim + wait | β
| β
| Queue | Low |
| SemaphoreSlim + WaitAsync(0) | β
| β
| Reject | Low |
| CancellationTokenSource | Depends | β | Cancel previous | Medium |
| Shared Task | With synchronization | β
| Share | Medium |
| Keyed semaphore | β | β | Per-resource policy | Medium/High |
| Explicit queue | β | β | Queue all | High |
There is no universally best option. The operation's semantics determine the correct strategy.
42. π§ Choosing the Correct Strategy
A useful decision table:
| Operation | Recommended behavior |
|---|---|
| Save button | Reject duplicate |
| Login | Reject duplicate |
| Navigation | Reject/serialize |
| Pull-to-refresh | Reject duplicate |
| Search | Cancel previous |
| Autocomplete | Debounce + cancel previous |
| Database writes | Serialize when required |
| Upload jobs | Queue |
| App initialization | Share existing task |
| Token refresh | Share existing task |
| Independent API calls | Allow concurrency |
| Same-resource mutation | Keyed serialization |
This is a much better architectural model than simply adding IsBusy everywhere.
43. ποΈ A Production Architecture
A larger .NET MAUI application might look like this:
βββββββββββββββββββββββββββββββ
β UI Layer β
β β
β Buttons / Gestures / Views β
ββββββββββββββββ¬βββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β ViewModels β
β β
β Commands + UI State β
ββββββββββββββββ¬βββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β Execution Policies β
β β
β Reject / Queue / Cancel β
β Single-flight / Keyed β
ββββββββββββββββ¬βββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β Application Services β
β β
β Navigation / Sync / Auth β
ββββββββββββββββ¬βββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β Infrastructure β
β β
β HTTP / SQLite / Storage β
βββββββββββββββββββββββββββββββ
This makes concurrency policy explicit instead of hiding it inside arbitrary UI event handlers.
44. π‘ Think in Terms of Execution Policies
The deeper lesson is that preventing duplicate async operations isn't really about SemaphoreSlim. It's about defining execution policy. For every important asynchronous operation, ask:
Can it run concurrently?
If yes:
Allow
If no:
What should happen to a second invocation?
Ignore it?
Wait?
Cancel the first?
Replace it?
Share the first?
Queue it?
Once that decision is explicit, the implementation becomes much easier.
45. π± Example: Production Save ViewModel
Putting several ideas together:
public sealed class EditCustomerViewModel : BaseViewModel
{
private readonly ICustomerService _customerService;
private readonly AsyncExecutionGuard _saveGuard = new();
private bool _isSaving; public bool IsSaving { get => _isSaving; private set { if (_isSaving == value) return; _isSaving = value; OnPropertyChanged(); OnPropertyChanged(nameof(CanSave)); } } public bool CanSave => !IsSaving; public Customer Customer { get; } public EditCustomerViewModel( ICustomerService customerService, Customer customer) { _customerService = customerService; Customer = customer; } public async Task SaveAsync( CancellationToken cancellationToken = default) { await _saveGuard.TryExecuteAsync( async token => { IsSaving = true; try { await _customerService.SaveAsync( Customer, token); } finally { IsSaving = false; } }, cancellationToken); } } ``` The design has several useful properties: β
duplicate execution is rejected β
UI state is independent β
cancellation propagates β
semaphore ownership is safely released β
service logic remains separate β
the operation is testable
---
# 46. π¬ Duplicate Operations vs Idempotency
One final distinction is important. Preventing duplicate execution in the client does **not** guarantee that a distributed operation executes exactly once.
Consider:
```text
MAUI App
β
βββ POST /orders
β
βΌ
Server
The client sends the request. The server creates the order. Then the network connection drops before the client receives the response. The client cannot know whether the operation completed. Retrying might create another order. A local semaphore cannot solve this.
For critical operations, the server may need an idempotency mechanism:
Request
β
βββ Idempotency-Key: abc123
β
βΌ
Server
β
βββ Already processed?
β β
β βββ Yes β return previous result
β β
β βββ No β execute
Therefore:
| Protection | Solves |
|---|---|
| Disable button | UX duplicate taps |
| Async guard | In-process concurrency |
| Single-flight | Duplicate in-flight work |
| Cancellation | Stale work |
| Idempotency key | Distributed duplicate execution |
Production systems often need more than one layer.
47. π Best Practices
When designing asynchronous operations in .NET MAUI:
- π¦ Define the execution policy explicitly.
- π Use async-compatible synchronization when actual mutual exclusion is required.
- π¨ Keep
IsBusyfor UI state rather than treating it as your only concurrency primitive. - π Propagate
CancellationTokenthrough the complete operation. - π Release synchronization primitives in
finally. - π§ Protect navigation from repeated invocation.
- π Avoid duplicate network requests when they provide no value.
- π Prefer latest-wins semantics for search.
- π¦ Consider single-flight for initialization and token refresh.
- πΎ Serialize writes only when the underlying resource requires it.
- π§© Keep synchronization scope as narrow as possible.
- π§ͺ Test concurrent invocation explicitly.
- β οΈ Keep
async voidat event boundaries. - π Use server-side idempotency for critical distributed operations.
- π Measure contention before introducing broad global locks.
π― Conclusion
Duplicate asynchronous execution is one of those problems that often looks harmless during development but becomes much more visible in production. A user double-taps a button. A lifecycle callback overlaps with initialization. Two navigation commands execute. A refresh starts while another refresh is running. Several requests discover an expired authentication token simultaneously. Each case looks slightly different, but underneath they all ask the same question:
What should happen when an asynchronous operation is requested while that operation is already running?
A simple IsBusy flag may be enough for small UI scenarios, but production applications benefit from treating concurrency as an explicit architectural concern. SemaphoreSlim provides a strong foundation for asynchronous mutual exclusion. Cancellation works well for latest-wins operations. Shared tasks provide single-flight semantics. Keyed synchronization can protect individual resources without serializing the entire application. The most important step is not choosing a particular primitive. It is choosing the correct execution policy.
Duplicate invocation
β
βΌ
What does this operation mean?
β
βββ Ignore duplicate βββΊ Reject
β
βββ Must run later βββββΊ Queue
β
βββ Newest matters βββββΊ Cancel previous
β
βββ Same work ββββββββββΊ Share task
β
βββ Different resource βΊ Allow / keyed lock
Once this becomes part of your architecture, asynchronous behavior becomes more predictable, testable, and maintainable. And in a mobile application, predictability is exactly what you want when users tap faster than your network can respond. ππ±
π References
- Microsoft Learn β .NET MAUI documentation
- Microsoft Learn β Task asynchronous programming model
- Microsoft Learn β SemaphoreSlim
- Microsoft Learn β Cancellation in managed threads
- Microsoft Learn β .NET MAUI Shell navigation
- Microsoft Learn β .NET MAUI dependency injection
- Microsoft Learn β CommunityToolkit.Mvvm AsyncRelayCommand
Was this useful?
Sign in to react. Guest comments are still welcome.




Comments (0)
No approved comments yet.