I still remember one of my early projects using .NET. It was a service integration task, I had to call an external API to retrieve transaction data and display it on our dashboard. The first version of the code worked… but it was really slow. Sometimes, the app would freeze while waiting for a response. I was confused and kept asking myself, “It’s just one HTTP call, why is the whole app stuck?”
That was my first encounter with asynchronous programming.
At that time, I didn’t fully understand what async and await really meant. I saw them in some StackOverflow answers, so I added them and hoped things would magically become fast. But instead, I ended up with weird bugs, deadlocks, freezing UI, and errors that were hard to trace.
Later on, as I worked on more projects and slowly started reading documentation (and honestly, lots of blog posts like this), I began to understand. Async programming is not just a syntax trick, it’s a mindset. It’s about designing your code in a way that doesn’t block, that can scale, and that works well even when things go wrong.
In this post, I want to share everything I’ve learned over the past few years, written not from a “perfect developer” point of view, but from someone who made a lot of mistakes and learned along the way. I hope it helps you avoid the same pain and save time.
What Is Async Programming in .NET?
Before jumping to best practices, let’s review the basics.
Asynchronous programming allows your application to stay responsive while waiting for long-running tasks like file I/O, web requests, or database operations. Instead of blocking the thread (and sometimes the whole application), async and await let .NET manage this more efficiently.
Here’s a very basic example:
public async Task<string> GetWeatherAsync()
{
using var client = new HttpClient();
var response = await client.GetStringAsync("https://weatherapi.com/today");
return response;
}
This method is non-blocking. While it waits for the response, the calling thread is free to do something else.
Best Practices in Async Programming
1. Go “Async All the Way”
Once one method becomes async, its callers usually need to be async too. This is called “async all the way”.
Bad:
public string GetData()
{
var task = GetDataAsync(); // blocking!
return task.Result; // potential deadlock!
}
Better:
public async Task<string> GetDataAsync()
{
var result = await SomeService.FetchSomethingAsync();
return result;
}
2. Avoid async void (Except for Event Handlers)
async void is dangerous because you can’t await it and you can’t catch exceptions from it.
Use this only in event handlers:
private async void OnButtonClick(object sender, EventArgs e)
{
await SaveDataAsync();
}
For everything else, prefer Task or Task:
public async Task SaveDataAsync()
{
// do something async
}
3. Always Use Try-Catch for Async Methods
Exceptions in async methods will be thrown when the await resumes. So always use proper error handling:
public async Task<string> LoadDataAsync()
{
try
{
var response = await httpClient.GetStringAsync("https://api.example.com/data");
return response;
}
catch (HttpRequestException ex)
{
Console.WriteLine($"Error: {ex.Message}");
return null;
}
}
4. Use ConfigureAwait(false) in Library Code
If you’re writing libraries, background workers, or non-UI code, use:
await SomeIOOperationAsync().ConfigureAwait(false);
This improves performance and avoids deadlocks by not forcing the continuation to resume on the original synchronization context (like UI threads).
5. Don’t Mix Synchronous and Async Code
Avoid using .Result
or .Wait()
with async methods:
var data = GetDataAsync().Result; // Bad
This can easily lead to deadlocks. Instead, make the calling method async and await it properly.
6. Don’t Overuse Task.Run
A common mistake is wrapping everything in Task.Run() just to make it async:
public Task<string> LoadData()
{
return Task.Run(() => GetDataFromDb());
}
Task.Run
is for offloading CPU-bound work, not I/O-bound tasks like HTTP requests or file reads. For I/O tasks, just use await.
public async Task<string> LoadDataAsync()
{
return await File.ReadAllTextAsync("file.txt");
}
Common Pitfalls (I’ve Experienced These!)
Deadlocks in UI or ASP.NET Contexts
This usually happens if you do .Result or .Wait() in code that runs on a synchronization context (e.g., a WPF or ASP.NET request thread). The task needs the thread to finish, but the thread is blocked. Avoid blocking async code!
Forgetting to Observe Exceptions
If you fire-and-forget a task like this:
DoSomethingAsync(); // fire-and-forget
…you may never catch the exception. At least log it:
_ = DoSomethingAsync().ContinueWith(t =>
{
Console.WriteLine(t.Exception);
}, TaskContinuationOptions.OnlyOnFaulted);
But in most cases, it’s better to await it or return it to the caller.
Performance Tips
Use Task.WhenAll for Parallel Async Tasks
var task1 = GetData1Async();
var task2 = GetData2Async();
await Task.WhenAll(task1, task2);
Avoid Async in Tight Loops Without WhenAll
Bad:
foreach (var item in items)
{
await DoWorkAsync(item); // slow and sequential
}
Better:
await Task.WhenAll(items.Select(item => DoWorkAsync(item)));
Summary: Key Points
- Use async/await all the way
- Avoid .Result and .Wait()
- Return Task instead of void
- Use ConfigureAwait(false) in libraries
- Catch exceptions using try-catch
- Don’t misuse Task.Run
- Use Task.WhenAll for better performance
Final Thoughts
Async programming in .NET changed the way I write applications. In the beginning, I was intimidated by the syntax and the weird bugs I kept seeing. But after understanding the concepts and learning the best practices, I’ve seen how powerful and clean async code can be.
If you’re still confused, that’s okay. I was too. Don’t rush it. Try small examples, break them, fix them, and things will start to make more sense.