Building a Request Coalescing Layer in .NET MAUI
⚡ Building a Request Coalescing Layer in .NET MAUI
Modern .NET MAUI applications often execute multiple asynchronous operations simultaneously. Different ViewModels, reusable components, and background services may request the same information at nearly the same time.
Consider an application with a dashboard displaying customer information, recent orders, and account statistics.
Three components might independently request the same customer profile:
CustomerViewModel ────────► GET /api/customers/42
OrdersViewModel ──────────► GET /api/customers/42
DashboardViewModel ───────► GET /api/customers/42
Although all three requests need identical data, the application sends three separate HTTP requests.
This introduces unnecessary network traffic, increases backend load, and wastes device resources. What if we could transform those three requests into a single operation?
CustomerViewModel ────────┐
│
OrdersViewModel ──────────┼──► Request Coalescing Layer
│ │
DashboardViewModel ───────┘ ▼
GET /api/customers/42
│
▼
Shared Result
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Customer Orders Dashboard
This technique is known as Request Coalescing, sometimes called Single-Flight Execution.
In this article, we'll implement a lightweight, reusable request coalescing layer in .NET MAUI using C#, ConcurrentDictionary, Task, and asynchronous programming techniques.
Our goal is simple: multiple callers requesting the same resource concurrently should share one underlying operation. 🚀
- 🧠 What Is Request Coalescing?
Request coalescing combines equivalent concurrent requests into a single operation. Imagine three components requesting the same product:
await productService.GetProductAsync(100);
Without coalescing:
Request A ──► HTTP Request 1
Request B ──► HTTP Request 2
Request C ──► HTTP Request 3
With coalescing:
Request A ──┐
Request B ──┼──► HTTP Request 1
Request C ──┘ │
▼
Shared Task
│
┌──────┼──────┐
▼ ▼ ▼
A B C
All callers await the same in-flight operation.
When that operation completes, each caller receives the result.
The key distinction is that request coalescing does not necessarily cache results.
It shares work currently in progress.
- 🔄 Request Coalescing vs Caching
Although both techniques reduce redundant work, they solve different problems.
| Feature | Request Coalescing | Caching |
|---|---|---|
| Primary purpose | Share concurrent operations | Reuse previous results |
| Stores completed results | No | Usually yes |
| Prevents duplicate in-flight requests | Yes | Not necessarily |
| Requires expiration policies | Usually no | Usually yes |
| Handles simultaneous callers | Yes | Depends on implementation |
| Useful for HTTP requests | Yes | Yes |
Consider:
10:00:00 → Request A starts
10:00:01 → Request B joins A
10:00:02 → Request A completes
10:00:10 → Request C starts
Request B shares the operation started by A.
Request C starts a new operation because the previous one has already completed.
This behavior makes coalescing useful even when caching is undesirable.
- 🧩 Designing the Coalescing Contract
Let's begin with a generic abstraction.
public interface IRequestCoalescer
{
Task<T> ExecuteAsync<T>(
string key,
Func<CancellationToken, Task<T>> operation,
CancellationToken cancellationToken = default);
}
The contract has three important elements.
Key: Identifies equivalent operations.
Operation: Defines the asynchronous work.
CancellationToken: Allows a caller to stop waiting.
Example:
var customer = await coalescer.ExecuteAsync(
"customer:42",
ct => customerApi.GetCustomerAsync(42, ct),
cancellationToken);
If another component executes the same operation with the same key while the first is still running, both callers share the result.
The key must uniquely represent the request's meaningful inputs.
For example:
customer:42
customer:43
products:category:5
orders:user:100:page:2
Avoid using the same key for operations that may produce different results.
- ⚙️ Implementing the Request Coalescer
We need a thread-safe structure capable of tracking active operations. ConcurrentDictionary is a natural candidate.
However, there is an important detail: ConcurrentDictionary.GetOrAdd may execute its value factory multiple times under contention.
To prevent duplicate execution, we'll store a Lazy<Task<object?>> rather than starting the operation directly inside the dictionary factory.
We'll also ensure that completed operations are removed, and that one caller canceling its wait does not cancel work shared by other callers.
using System.Collections.Concurrent;
public sealed class RequestCoalescer : IRequestCoalescer
{
private readonly ConcurrentDictionary<string, Lazy<Task<object?>>> _inFlight =
new(StringComparer.Ordinal);
public async Task<T> ExecuteAsync<T>(
string key,
Func<CancellationToken, Task<T>> operation,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(operation);
cancellationToken.ThrowIfCancellationRequested();
var lazy = _inFlight.GetOrAdd(
key,
_ => new Lazy<Task<object?>>(
() => ExecuteAndCleanupAsync(
key,
operation),
LazyThreadSafetyMode.ExecutionAndPublication));
var result = await lazy.Value.WaitAsync(
cancellationToken);
return (T)result!;
}
private async Task<object?> ExecuteAndCleanupAsync<T>(
string key,
Func<CancellationToken, Task<T>> operation)
{
try
{
// The shared operation is independent of
// individual callers' cancellation tokens.
return await operation(CancellationToken.None);
}
finally
{
// Cleanup is coordinated with dictionary ownership
// in the production-safe version discussed below.
}
}
}
This illustrates the essential mechanism:
Key
│
▼
ConcurrentDictionary
│
▼
Lazy<Task<T>>
│
├── Caller A
├── Caller B
└── Caller C
But we still need to handle cleanup correctly.
A subtle race condition can occur if a very fast operation finishes before its dictionary entry is fully established. Removing entries only inside the operation's finally block can leave a completed task permanently registered.
Let's address that in the complete implementation.
- 🔒 A Production-Safe Implementation
For a compact implementation with predictable cleanup, we can use a regular dictionary protected by a lock.
The lock protects only dictionary operations—not the HTTP request itself.
This avoids unnecessary complexity while providing a clear ownership model.
public sealed class RequestCoalescer : IRequestCoalescer
{
private readonly object _sync = new();
private readonly Dictionary<string, Task<object?>> _inFlight =
new(StringComparer.Ordinal);
public async Task<T> ExecuteAsync<T>(
string key,
Func<CancellationToken, Task<T>> operation,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(operation);
cancellationToken.ThrowIfCancellationRequested();
// Include the result type in the internal key to
// avoid sharing incompatible generic result types.
var internalKey = $"{typeof(T).AssemblyQualifiedName}:{key}";
Task<object?> sharedTask;
lock (_sync)
{
if (!_inFlight.TryGetValue(
internalKey,
out sharedTask!))
{
var completion =
new TaskCompletionSource<object?>(
TaskCreationOptions.RunContinuationsAsynchronously);
sharedTask = completion.Task;
_inFlight.Add(internalKey, sharedTask);
_ = RunOperationAsync(
internalKey,
operation,
completion);
}
}
var result = await sharedTask.WaitAsync(
cancellationToken);
return (T)result!;
}
private async Task RunOperationAsync<T>(
string key,
Func<CancellationToken, Task<T>> operation,
TaskCompletionSource<object?> completion)
{
try
{
var result = await operation(
CancellationToken.None);
completion.TrySetResult(result);
}
catch (OperationCanceledException)
{
completion.TrySetCanceled();
}
catch (Exception ex)
{
completion.TrySetException(ex);
}
finally
{
lock (_sync)
{
_inFlight.Remove(key);
}
}
}
}
How it works
- The first caller registers a pending task.
- The underlying operation starts.
- Subsequent callers find the existing task.
- All callers await that task.
- The result or exception is propagated.
- The dictionary entry is removed after execution.
Importantly, the operation is never awaited while holding the lock.
The lock only protects the brief registration and cleanup steps.
A completed operation can have a very small interval before removal during which a new caller still observes its completed task. If strict in-flight-only behavior is required, cleanup and completion publication can be ordered more carefully. For most HTTP read scenarios, this brief handoff is acceptable.
- 🌐 Integrating with an HTTP Service
Let's integrate the coalescer into a typical .NET MAUI service. First, define a model:
public sealed record Product(
int Id,
string Name,
decimal Price);
Then create a service:
public sealed class ProductService
{
private readonly HttpClient _httpClient;
private readonly IRequestCoalescer _coalescer;
public ProductService(
HttpClient httpClient,
IRequestCoalescer coalescer)
{
_httpClient = httpClient;
_coalescer = coalescer;
}
public Task<Product?> GetProductAsync(
int productId,
CancellationToken cancellationToken = default)
{
return _coalescer.ExecuteAsync(
$"product:{productId}",
ct => _httpClient.GetFromJsonAsync<Product>(
$"api/products/{productId}",
ct),
cancellationToken);
}
}
Now imagine three ViewModels:
var task1 = productService.GetProductAsync(100);
var task2 = productService.GetProductAsync(100);
var task3 = productService.GetProductAsync(100);
await Task.WhenAll(task1, task2, task3);
Without request coalescing:
3 callers → 3 HTTP requests
With request coalescing:
3 callers → 1 HTTP request
This is especially valuable when multiple components initialize simultaneously.
- 🔑 Designing Reliable Request Keys
A request key must represent the complete identity of an operation.
Consider:
$"products:{categoryId}"
This may be insufficient if the request also depends on:
Page number
Page size
Sort order
Search term
User identity
Tenant
Language
A better key might be:
var key =
$"products:{categoryId}:" +
$"page:{page}:" +
$"size:{pageSize}:" +
$"sort:{sortOrder}";
For authenticated requests, ensure that requests belonging to different users or tenants cannot accidentally share results.
A coalescing key is not merely a performance optimization detail.
It is part of the correctness and isolation boundary.
For enterprise applications, I recommend constructing keys through a dedicated service rather than manually concatenating strings throughout the codebase.
- 🛑 Cancellation Semantics
Cancellation is one of the most important design decisions.
Imagine:
ViewModel A ──┐
├──► Shared HTTP Request
ViewModel B ──┘
ViewModel A disappears because the user navigates away.
Should its cancellation stop the HTTP request?
Not necessarily.
ViewModel B still needs the result.
That's why the implementation uses:
await sharedTask.WaitAsync(cancellationToken);
This cancels the caller's wait without automatically canceling the shared operation.
Caller A cancels
│
▼
A stops waiting
Caller B continues
│
▼
Shared request completes
There is a trade-off: the shared operation may continue even when no callers remain.
For a more advanced implementation, you can add reference counting and cancel the underlying operation only when all interested callers have left.
You should also configure an appropriate HTTP timeout, because a shared operation that never completes can keep its dictionary entry indefinitely.
- 💥 Error Handling
What happens when the shared HTTP request fails?
Caller A ──┐
Caller B ──┼──► Shared Request ──► HTTP 500
Caller C ──┘
All callers receive the same failure. For example:
try
{
var product =
await productService.GetProductAsync(100);
}
catch (HttpRequestException ex)
{
logger.LogError(
ex,
"Unable to retrieve product.");
}
The coalescer should not silently swallow failures or convert them into successful results.
After a failed operation is removed, a subsequent request can execute again.
This means failures are not permanently cached.
Retries should be handled by a separate resilience policy when appropriate.
- 💉 Registering the Coalescer in .NET MAUI
Register the coalescer as a singleton in MauiProgram.cs.
builder.Services.AddSingleton<
IRequestCoalescer,
RequestCoalescer>();
builder.Services.AddHttpClient<ProductService>(
client =>
{
client.BaseAddress =
new Uri("https://api.example.com/");
client.Timeout =
TimeSpan.FromSeconds(30);
});
The singleton lifetime is important.
If every ViewModel receives a separate coalescer, requests won't be shared across components.
ViewModel A → Coalescer A
ViewModel B → Coalescer B
ViewModel C → Coalescer C
Instead:
ViewModel A ──┐
ViewModel B ──┼──► Singleton Coalescer
ViewModel C ──┘
If you're using an HTTP client library or custom REST abstraction, the same pattern can be placed above that layer.
The coalescer does not need to know whether the underlying request uses HttpClient, Refit, or another implementation.
- 📊 Observability
Request coalescing should be measurable.
Useful metrics include:
| Metric | Description |
|---|---|
| Executed Requests | Underlying operations started |
| Coalesced Requests | Callers that joined existing operations |
| Active Operations | Current in-flight requests |
| Failed Operations | Shared operations that failed |
| Execution Duration | Time spent performing the underlying operation |
For example:
Total callers: 1,000
Underlying requests: 420
Coalesced callers: 580
In this scenario, 580 callers avoided starting an additional underlying operation.
That doesn't necessarily mean a 58% improvement in response time, but it demonstrates a substantial reduction in redundant work.
Use structured logging to record whether a caller created a new operation or joined an existing one.
Avoid logging sensitive request keys if they contain user information.
- 🧪 Testing Request Coalescing
The most important test verifies that concurrent callers share one operation.
[Fact]
public async Task ConcurrentRequests_ExecuteOnlyOnce()
{
var coalescer = new RequestCoalescer();
var executionCount = 0;
var gate = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
Task<int> ExecuteAsync(
CancellationToken cancellationToken)
{
Interlocked.Increment(ref executionCount);
return WaitForResultAsync();
}
async Task<int> WaitForResultAsync()
{
await gate.Task;
return 42;
}
var requests = Enumerable.Range(0, 10)
.Select(_ => coalescer.ExecuteAsync(
"test-request",
ExecuteAsync))
.ToArray();
gate.SetResult(true);
var results = await Task.WhenAll(requests);
Assert.All(
results,
result => Assert.Equal(42, result));
Assert.Equal(1, executionCount);
}
For a deterministic concurrency test, hold the shared operation open until all ten callers have entered the coalescer. Otherwise, scheduling can allow the operation to finish before some callers join, making the assertion timing-dependent.
Additional tests should cover:
- Different keys execute independently.
- Identical keys share the same operation.
- Exceptions propagate to all callers.
- Failed operations can be retried.
- One caller's cancellation does not cancel another caller.
- Completed operations are eventually removed.
- Requests with different result types do not share incompatible tasks.
These tests are essential because concurrency defects often remain invisible during normal manual testing.
- ⚠️ When Not to Use Request Coalescing
Request coalescing is particularly suitable for idempotent read operations.
For example:
GET /api/products/100
GET /api/customers/42
GET /api/configuration
Be more careful with operations that produce business side effects:
POST /api/orders
POST /api/payments
POST /api/transfers
Two requests with the same payload do not necessarily represent the same business intent.
For write operations, use explicit business identifiers and server-side idempotency when duplicate execution must be prevented.
Request coalescing is not a substitute for idempotency.
- 🏗️ Suggested Architecture
A clean implementation can fit naturally into a layered .NET MAUI application.
┌───────────────────────────────────────┐
│ .NET MAUI UI │
│ │
│ ViewModels / Pages / Components │
└──────────────────┬────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ Application Services │
│ │
│ ProductService / CustomerService │
└──────────────────┬────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ Request Coalescing Layer │
│ │
│ • Request identity │
│ • In-flight operation tracking │
│ • Shared task execution │
│ • Caller cancellation │
│ • Cleanup │
└──────────────────┬────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ HTTP Layer │
│ │
│ HttpClient / REST Client │
└──────────────────┬────────────────────┘
│
▼
REST API
This keeps coalescing independent from navigation, ViewModels, and transport implementation details.
- 🏆 Best Practices
When implementing request coalescing in .NET MAUI:
- ⚡ Share only equivalent concurrent operations.
- 🔑 Use deterministic keys containing all relevant request parameters.
- 🔐 Include user or tenant context when results are authorization-dependent.
- 🧵 Protect in-flight operation registration against concurrency races.
- 🛑 Separate caller cancellation from shared operation cancellation.
- ⏱️ Apply timeouts to underlying requests.
- 💥 Propagate exceptions consistently.
- 🧹 Remove completed operations from the registry.
- 💉 Register the coalescer with an appropriate shared lifetime.
- 📊 Measure how many redundant operations are avoided.
- 🧪 Test concurrent execution deterministically.
- 🚫 Don't confuse request coalescing with caching or business idempotency.
🎯 Conclusion
In complex .NET MAUI applications, multiple components frequently request the same information at nearly the same time.
Without coordination, these requests produce unnecessary HTTP traffic, increased backend load, and redundant asynchronous work.
A request coalescing layer introduces a simple but powerful optimization:
Multiple Callers
│
▼
Request Identity
│
▼
In-Flight Operation Registry
│
▼
Single Async Operation
│
▼
Shared Result
By combining deterministic request keys, controlled concurrency, shared tasks, independent caller cancellation, and reliable cleanup, we can create a reusable mechanism that improves application efficiency without coupling it to a specific API client.
The most important principle is:
When multiple callers need the same result at the same time, perform the work once and share the outcome.
Request coalescing is particularly valuable for dashboards, shared data services, application initialization, concurrent ViewModel loading, and network-intensive mobile applications.
And when combined with caching, retry policies, and appropriate observability, it becomes another useful building block for resilient, production-ready .NET MAUI architectures. ⚡🚀
Was this useful?
Sign in to react. Guest comments are still welcome.


 Layer in .NET MAUI/RASPMAUI.png)

Comments (0)
No approved comments yet.