Asynchronous programming is one of the most important concepts for modern .NET developers. Whether you're building ASP.NET Core APIs, microservices, Azure Functions, desktop applications, or background workers, understanding async/await is essential for writing scalable and responsive applications.
This article covers:
What async/await really does
Common pitfalls and their solutions
Task vs ValueTask
ConfigureAwait(false)
Exception handling
Performance considerations
Best practices for production systems
Understanding Async/Await
Many developers think async/await creates a new thread.
It doesn't.
Instead, it allows a thread to be released while waiting for an I/O operation.
public async Task<User> GetUserAsync(int id)
{
return await _repository.GetUserAsync(id);
}
Execution Flow:
Request Thread
│
▼
GetUserAsync()
│
▼
Database Call
│
├── Waiting
│
└── Thread Returned To Pool
│
▼
Database Response
│
▼
Continuation Executes
Result:
Better scalability
Fewer blocked threads
Higher throughput
Common Async Pitfalls
1. Avoid async void
Problem
public async void SendEmail()
{
throw new Exception("Error");
}
Issues:
Exceptions cannot be awaited
Difficult to test
Fire-and-forget behavior
Crashes application unexpectedly
Correct
public async Task SendEmailAsync()
{
throw new Exception("Error");
}
Rule
Use:
Task
Task<T>
ValueTask
ValueTask<T>
Avoid:
async void
Exception:
Button_Click
Event Handlers
2. Never Use .Result or .Wait()
Problem
var user = GetUserAsync().Result;
or
GetUserAsync().Wait();
Issues:
Thread blocking
Deadlocks
Poor scalability
ASP.NET Example:
Thread 1 waits for async operation
Async operation waits for Thread 1
Deadlock
Correct
var user = await GetUserAsync();
Rule:
Async All The Way
If a method becomes async, propagate async upwards.
3. ConfigureAwait(false)
Problem
By default:
await SomeOperationAsync();
captures the current synchronization context.
UI Thread
↓
await
↓
Resume on UI Thread
This can be unnecessary and slower.
Correct
await SomeOperationAsync()
.ConfigureAwait(false);
Benefits:
Avoids context capture
Better performance
Prevents deadlocks
Where to Use
Library Code:
public async Task ProcessAsync()
{
await CallApiAsync()
.ConfigureAwait(false);
}
Where NOT to Use
UI Applications:
await LoadDataAsync();
label.Text = "Loaded";
Need UI context.
ASP.NET Core?
Generally unnecessary.
ASP.NET Core has no SynchronizationContext.
ASP.NET MVC (.NET Framework)
Use ConfigureAwait(false)
ASP.NET Core
Usually no benefit
4. Don't Wrap Async I/O in Task.Run
Problem
public async Task<User> GetUserAsync()
{
return await Task.Run(() =>
_repository.GetUserAsync());
}
This wastes a thread pool thread.
Correct
public Task<User> GetUserAsync()
{
return _repository.GetUserAsync();
}
or
public async Task<User> GetUserAsync()
{
return await _repository.GetUserAsync();
}
When Task.Run Is Appropriate
CPU-bound work:
await Task.Run(() =>
{
GenerateLargeReport();
});
Examples:
Image processing
PDF generation
Encryption
Compression
5. Handle Exceptions in Task.WhenAll
Problem
Task.WhenAll(tasks);
Not awaited.
Exceptions disappear.
Correct
try
{
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
// Handle errors
}
Multiple Exceptions
try
{
await Task.WhenAll(tasks);
}
catch
{
foreach (var task in tasks)
{
if (task.Exception != null)
{
// Log
}
}
}
Understanding Task
Task represents an asynchronous operation.
Task
Task<T>
Example:
public async Task<string> GetDataAsync()
{
return await File.ReadAllTextAsync("file.txt");
}
Task Internals
Task is a reference type.
public class Task
{
}
Stored on:
Heap
Every Task allocation creates an object.
Heap Allocation
↓
GC Pressure
Usually not a problem.
However, in extremely high-frequency methods, allocations matter.
What Is ValueTask?
Introduced to reduce allocations.
ValueTask<T>
is a struct.
public readonly struct ValueTask<T>
{
}
Stored inline.
Stack
or
Containing Object
No Task allocation required for synchronous completion.
Why ValueTask Exists
Imagine:
public async Task<int> GetCountAsync()
{
return _cache.Count;
}
Returns immediately.
Yet every call allocates a Task.
Better
public ValueTask<int> GetCountAsync()
{
return ValueTask.FromResult(_cache.Count);
}
No allocation.
Task vs ValueTask
Task
public Task<int> GetCountAsync()
{
return Task.FromResult(100);
}
Allocates:
Task Object
ValueTask
public ValueTask<int> GetCountAsync()
{
return ValueTask.FromResult(100);
}
Allocates:
Nothing
for completed operations.
When To Use ValueTask
Use when operations often complete synchronously.
Examples:
Memory Cache
public ValueTask<User?> GetAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
return ValueTask.FromResult(user);
return FetchFromDatabaseAsync(id);
}
Most calls hit cache.
No allocation.
Object Pool
public ValueTask<MyObject> RentAsync()
{
if (_pool.TryGet(out var obj))
return ValueTask.FromResult(obj);
return CreateAsync();
}
High Performance Libraries
Examples:
Kestrel
System.IO.Pipelines
Channels
ASP.NET internals
When NOT To Use ValueTask
Avoid for ordinary application code.
Bad:
public ValueTask<User> GetUserAsync()
{
return new ValueTask<User>(
_repository.GetUserAsync());
}
No benefit.
Adds complexity.
Use Task instead:
public Task<User> GetUserAsync()
{
return _repository.GetUserAsync();
}
ValueTask Limitations
A ValueTask should typically be awaited only once.
Bad:
var result = GetAsync();
await result;
await result;
Can fail.
Task supports:
await task;
await task;
await task;
Perfectly valid.
Decision Matrix
| Scenario | Use |
|---|---|
| ASP.NET Core APIs | Task |
| Database calls | Task |
| HTTP calls | Task |
| File I/O | Task |
| Background services | Task |
| Cache hits frequently | ValueTask |
| Object pooling | ValueTask |
| High-performance libraries | ValueTask |
| Framework development | ValueTask |
Async Best Practices Checklist
✅ Return Task or Task<T>
✅ Use ValueTask only when profiling proves allocations matter
✅ Await every async call
✅ Use Task.WhenAll for parallel work
✅ Handle exceptions properly
✅ Use Task.Run only for CPU-bound work
✅ Prefer cancellation tokens
✅ Avoid .Wait() and .Result
✅ Use ConfigureAwait(false) in reusable libraries
✅ Follow "Async All The Way"
Final Recommendation
For 95% of enterprise applications, including ASP.NET Core APIs, microservices, Azure Functions, and background workers:
Task
Task<T>
should be your default choice.
Use ValueTask only when:
The method is called extremely frequently.
It often completes synchronously.
Profiling shows Task allocations are a measurable bottleneck.
A simple rule used by many .NET runtime and ASP.NET Core engineers is:
Default → Task
Measure Performance
↓
Need Allocation Optimization?
↓
Use ValueTask
This keeps code simple, maintainable, and performant while avoiding premature optimization.