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

  1. Understanding the Problem
  2. How Duplicate Operations Happen
  3. Why async Changes the Execution Model
  4. The Classic IsBusy Approach
  5. Why IsBusy Can Fail
  6. Using SemaphoreSlim
  7. Creating a Reusable Async Execution Guard
  8. Returning Results from Protected Operations
  9. Supporting Cancellation
  10. Preventing Duplicate MVVM Commands
  11. CommunityToolkit.Mvvm and Async Commands
  12. Preventing Duplicate Navigation
  13. Protecting HTTP Requests
  14. Protecting Database Operations
  15. Refresh and Synchronization Scenarios
  16. Latest-Wins Operations
  17. Queue vs Reject vs Cancel
  18. Exception Handling
  19. UI State and Progress
  20. Dependency Injection
  21. Testing Concurrent Operations
  22. Performance Considerations
  23. Common Mistakes
  24. Choosing the Right Strategy
  25. Production Architecture
  26. Conclusion
  27. 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:

  1. 🚦 Define the execution policy explicitly.
  2. πŸ”’ Use async-compatible synchronization when actual mutual exclusion is required.
  3. 🎨 Keep IsBusy for UI state rather than treating it as your only concurrency primitive.
  4. πŸ›‘ Propagate CancellationToken through the complete operation.
  5. πŸ”“ Release synchronization primitives in finally.
  6. 🧭 Protect navigation from repeated invocation.
  7. 🌐 Avoid duplicate network requests when they provide no value.
  8. πŸ”Ž Prefer latest-wins semantics for search.
  9. πŸ“¦ Consider single-flight for initialization and token refresh.
  10. πŸ’Ύ Serialize writes only when the underlying resource requires it.
  11. 🧩 Keep synchronization scope as narrow as possible.
  12. πŸ§ͺ Test concurrent invocation explicitly.
  13. ⚠️ Keep async void at event boundaries.
  14. πŸ” Use server-side idempotency for critical distributed operations.
  15. πŸ“Š 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


Was this useful?

Comments (0)

Leave a comment

Submit for moderation
An unhandled error has occurred. Reload πŸ—™