Using async and await Deliberately

1. Introduction: async is useful, but not automatic

async and await are among the most useful features in modern C# development. They make it possible to write asynchronous code that still reads in a mostly sequential way. A method can start an operation, wait for the result, and continue without forcing the calling thread to stay blocked during the wait.

That is especially valuable in applications that work with databases, files, web APIs, email services, or user interfaces. Many real operations spend most of their time waiting for something outside the CPU to finish.

But because async and await are so convenient, they can also be overused.

A method should not become asynchronous just because asynchronous code looks modern. It should become asynchronous because the operation it represents may need to wait in a way that should not block the caller.

For example, this kind of method is a natural candidate for asynchronous code:

public async Task<Article?> GetByIdAsync(string id, CancellationToken ct)
{
    return await _articleStore.GetByIdAsync(id, ct);
}

The method may involve file access, database access, or another operation that can take time outside the immediate control of the current thread.

But this kind of method is usually not improved by adding async:

public async Task<int> CalculateTitleLengthAsync(string title)
{
    return title.Trim().Length;
}

There is no meaningful wait here. The method performs immediate CPU work and returns a result. Making it asynchronous adds ceremony without improving responsiveness, scalability, or clarity.

That distinction matters.

Asynchronous programming is not mainly about making individual operations faster. It is about allowing the caller to continue using its thread while the operation waits for something else. In a UI application, that can keep the interface responsive. In a web application, it can allow the server to handle other requests while waiting for I/O. In background processing, it can make long-running workflows easier to cancel and coordinate.

Used deliberately, async and await make code more responsive, scalable, and honest about waiting.

Used casually, they can make code harder to read, harder to test, and harder to reason about.

The goal is not to make everything asynchronous.

The goal is to make asynchronous boundaries clear.

2. What async and await are really for

async and await are often misunderstood as a way to make code run faster.

That is not their main purpose.

An asynchronous method does not automatically make the operation itself faster. A database query still takes the time it takes. A file still needs to be read. A web request still has to wait for the remote server to respond.

The difference is how the caller waits.

In synchronous code, the calling thread is blocked while the operation is waiting:

var article = _articleRepository.GetById(id);

If this call waits for disk, database, or network I/O, the calling thread waits too. In a desktop application, that may freeze the UI. In a web application, that may tie up a request thread that could otherwise be used for other work.

With asynchronous code, the method can yield control while the operation is waiting:

var article = await _articleRepository.GetByIdAsync(id, ct);

The code still reads as if it continues step by step, but the thread is not held unnecessarily while the awaited operation is incomplete.

That is the real value of await.

It lets the code express:

Continue here when the operation has completed, but do not block the current thread while waiting.

This is most useful for operations that are I/O-bound rather than CPU-bound.

I/O-bound operations spend much of their time waiting for something external:

  1. Reading or writing files.
  2. Querying a database.
  3. Calling a web API.
  4. Sending email.
  5. Waiting for a timer.
  6. Communicating with another process or service.

CPU-bound operations are different. They spend their time actively using the processor:

  1. Parsing a large document.
  2. Compressing data.
  3. Rendering an image.
  4. Calculating a complex result.
  5. Processing a large in-memory collection.

async and await are naturally suited for I/O-bound work. They allow the application to avoid blocking while it waits.

They do not, by themselves, move CPU-bound work to another thread.

For example, this method is asynchronous because it awaits I/O:

public async Task SaveArticleAsync(Article article, CancellationToken ct)
{
    await _articleRepository.SaveAsync(article, ct);
}

This method, however, does not become meaningfully asynchronous just because it returns a Task:

public Task<int> CountWordsAsync(string text)
{
    var count = text
        .Split(' ', StringSplitOptions.RemoveEmptyEntries)
        .Length;

    return Task.FromResult(count);
}

It may be technically asynchronous in shape, but it is not asynchronous in behavior. It does all its work immediately and then wraps the result in a completed task.

That may sometimes be useful to satisfy an interface, but it should not be confused with real asynchronous work.

A useful distinction is:

async is not about making work faster. It is about not blocking while work is waiting.

This is why naming also matters.

A method named GetByIdAsync should normally represent an operation that may genuinely wait. If the method only performs immediate in-memory work, the async suffix can be misleading.

There are exceptions. Sometimes an interface is asynchronous because some implementations need to wait, even if one implementation is currently in-memory. For example, a test implementation or memory-backed repository may return Task.FromResult(...) because the production implementation uses a database or file store.

That is reasonable.

The important thing is that the asynchronous boundary reflects the real nature of the abstraction, not just the current implementation detail.

If the abstraction represents storage, network communication, external services, timers, or other waiting operations, asynchronous methods often make sense.

If the abstraction represents immediate calculation, formatting, mapping, or simple in-memory decisions, asynchronous methods usually add noise.

The best use of async and await is therefore deliberate.

They should appear where the code is honestly saying:

This operation may need to wait, and the caller should not be blocked while it does.

3. Good reasons to use async

A good reason to use async is that the method represents work that may need to wait.

That wait may be short most of the time, but it is still a wait. The important point is not whether the operation is usually fast. The important point is whether the operation depends on something outside the immediate CPU flow of the current method.

Database access is a common example:

public async Task<Article?> GetByIdAsync(string id, CancellationToken ct)
{
    return await _dbContext.Articles.FirstOrDefaultAsync(
        article => article.Id == id, ct);
}

The query may be fast, but it still depends on the database engine. The calling thread should not need to block while the database processes the request.

File access is another common example:

public async Task<string> ReadArticleMarkdownAsync(
    string path,
    CancellationToken ct)
{
    return await File.ReadAllTextAsync(path, ct);
}

The same applies to HTTP calls, email delivery, cloud storage, message queues, external APIs, and other services that may respond later.

For example:

public async Task SendConfirmationEmailAsync(
    EmailMessage message,
    CancellationToken ct)
{
    await _emailSender.SendAsync(message, ct);
}

In these cases, async makes the waiting explicit and allows the caller to continue without blocking the current thread.

Another good reason to use async is UI responsiveness.

In a desktop application, long-running or waiting operations should not block the UI thread. If the UI thread is blocked, the application may stop repainting, stop responding to input, or appear frozen.

For example:

private async void SaveButton_Click(object sender, EventArgs e)
{
    SaveButton.Enabled = false;

    try
    {
        await _articleService.SaveAsync(BuildRequest(),
            CancellationToken.None);
    }
    finally
    {
        SaveButton.Enabled = true;
    }
}

The details will vary between UI frameworks, but the principle is the same. The UI should be able to remain responsive while the operation is waiting.

A third reason is scalability in server applications.

In a web application, a request that waits synchronously for a database or external service keeps a thread occupied. If many requests do that at the same time, the server may waste threads on waiting.

Asynchronous I/O allows the server to use its threads more efficiently while requests are waiting for external operations to complete.

For example:

public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
    var result = await _articleService.UpdateArticleAsync(Request, ct);

    if (!result.Succeeded)
    {
        return Page();
    }

    return RedirectToPage("/Admin/Articles");
}

The method does not become faster simply because it is asynchronous. But while it waits for the underlying service, repository, or file operation, the server is not forced to dedicate a thread to doing nothing.

A fourth reason is cancellation.

Asynchronous methods often represent operations that may take enough time that the caller should be able to cancel them. This is why CancellationToken belongs naturally with many async methods.

For example:

public async Task<IReadOnlyList<ArticleSummary>> SearchArticlesAsync(
    string query,
    CancellationToken ct)
{
    return await _articleRepository.SearchAsync(query, ct);
}

The token gives the caller a way to say that the result is no longer needed. In a web request, this may happen if the client disconnects. In a UI application, it may happen if the user closes a window, starts a new search, or cancels an operation.

A fifth reason is composition.

Asynchronous methods can be composed in workflows that involve several waiting operations:

public async Task<PublishArticleResult> PublishArticleAsync(
    string articleId,
    CancellationToken ct)
{
    var article = await _articleRepository.GetByIdAsync(articleId, ct);

    if (article is null)
    {
        return PublishArticleResult.Failed("Article was not found.");
    }

    article.Publish(_clock.UtcNow);

    await _articleRepository.SaveAsync(article, ct);
    await _notificationService.NotifyArticlePublishedAsync(
        article.Id, ct);

    return PublishArticleResult.Success(article.Id);
}

The application service still reads like a sequence of steps, but each awaited operation can complete asynchronously.

That is one of the strengths of async and await: they let asynchronous workflows remain readable.

Good reasons to use async therefore include:

  1. The method performs database, file, network, or external-service I/O.
  2. The method must not block the UI thread.
  3. The method is part of a web request path that waits for I/O.
  4. The operation should support cancellation.
  5. The method coordinates other asynchronous operations.
  6. The abstraction represents something that may wait, even if one implementation currently completes immediately.

The common theme is waiting.

If the method may need to wait, and blocking the caller would be undesirable, async is usually appropriate.

4. When async does not help

async is useful when a method needs to wait without blocking the caller.

It is less useful when there is no real waiting involved.

A common mistake is to make a method asynchronous simply because other nearby methods are asynchronous, or because async feels like the modern default. That can make the code look consistent on the surface, but it may also add unnecessary complexity.

For example:

public async Task<string> NormalizeTitleAsync(string title)
{
    return title.Trim();
}

This method does not benefit from being asynchronous. It does immediate in-memory work and returns a result. There is no database call, no file access, no network request, no timer, and no external operation to wait for.

In many projects, this code will also produce a compiler warning because the method is marked async but does not use await.

A cleaner version is synchronous:

public string NormalizeTitle(string title)
{
    return title.Trim();
}

The same applies to many small calculations, formatting operations, mappings, and simple domain decisions.

For example, this should normally not be asynchronous:

public async Task<bool> CanPublishAsync(Article article)
{
    return !string.IsNullOrWhiteSpace(article.Title)
           && !string.IsNullOrWhiteSpace(article.ContentMarkdown);
}

There is no meaningful waiting here. The method checks state that is already in memory.

A synchronous version is clearer:

public bool CanPublish(Article article)
{
    return !string.IsNullOrWhiteSpace(article.Title)
           && !string.IsNullOrWhiteSpace(article.ContentMarkdown);
}

This matters because asynchronous code comes with a cost.

The cost is not usually large, but it is real. An async method creates a different control flow. It returns a Task or ValueTask. It may involve a generated state machine. It changes how errors are propagated. It affects tests, interfaces, and callers.

That cost is worthwhile when the method represents real asynchronous work.

It is just noise when the method only performs immediate calculation.

Another common mistake is wrapping synchronous work in Task.Run just to make the method look asynchronous:

public Task<int> CalculateScoreAsync(Article article)
{
    return Task.Run(() => CalculateScore(article));
}

This may move the work to another thread, but that is not the same as asynchronous I/O. The CPU work still has to happen. It is simply being scheduled somewhere else.

That may be useful in some UI scenarios where expensive CPU work must not block the UI thread. But it should be a deliberate choice, not a default pattern.

For server-side code, wrapping CPU work in Task.Run is often a bad trade-off. A web server already uses thread-pool threads to handle requests. Moving work to another thread-pool thread may add overhead without improving scalability.

A useful distinction is:

async helps when code is waiting. It does not make ordinary CPU work disappear.

There are also cases where an asynchronous shape is justified even if a specific implementation completes synchronously.

For example, an interface may represent storage:

public interface IArticleRepository
{
    Task<Article?> GetByIdAsync(string id, CancellationToken ct);
}

That is reasonable because the abstraction represents something that may wait, even though this specific implementation does not.

The important difference is intent.

The interface is asynchronous because repository access may involve I/O. The test implementation is only adapting to that contract.

That is different from making a purely in-memory calculation asynchronous without a reason.

async also does not help when the caller immediately blocks on the result:

var article = _articleRepository.GetByIdAsync(id, ct).Result;

or:

var article = _articleRepository.GetByIdAsync(id, ct)
    .GetAwaiter()
    .GetResult();

This defeats much of the purpose of asynchronous code. It turns an asynchronous operation back into a blocking wait, and in some application types it can contribute to deadlocks or thread starvation.

If a method calls asynchronous code, the better solution is usually to make the calling method asynchronous too:

var article = await _articleRepository.GetByIdAsync(id, ct);

That is why asynchronous code often spreads through a call chain. This is not a failure of the feature. It is the result of keeping the waiting behavior explicit.

Async does not help much when:

  1. The method performs only simple in-memory work.
  2. The method only formats, maps, or normalizes data.
  3. The method performs a quick domain decision using already-loaded state.
  4. The method wraps synchronous CPU work without a deliberate reason.
  5. The caller blocks immediately on the returned task.
  6. The async suffix is added only for naming consistency, not because the abstraction may wait.

The decision should be based on what the method represents.

If the method represents waiting, async may be the right tool.

If the method represents immediate work, synchronous code is often clearer.

5. Async in UI applications

In UI applications, one of the most important reasons to use async is responsiveness.

Most UI frameworks have a main UI thread. That thread is responsible for processing input, updating controls, repainting the screen, and keeping the application responsive. If that thread is blocked, the user interface may freeze.

The user may not know whether the application is working, waiting, or stuck.

That is why waiting operations should normally not run synchronously on the UI thread.

For example, this kind of code can be problematic:

private void SaveButton_Click(object sender, EventArgs e)
{
    SaveButton.Enabled = false;

    _articleService.SaveArticle(request);

    SaveButton.Enabled = true;
}

If SaveArticle performs file access, database access, network communication, or another slow operation, the UI thread is blocked until the method returns. During that time, the window may not repaint, buttons may not respond, and the application may feel frozen.

An asynchronous version is usually better:

private async void SaveButton_Click(object sender, EventArgs e)
{
    SaveButton.Enabled = false;

    try
    {
        await _articleService.SaveArticleAsync(request,
            CancellationToken.None);
    }
    finally
    {
        SaveButton.Enabled = true;
    }
}

In this version, the UI method still reads as a sequence of steps. The button is disabled, the save operation is awaited, and the button is enabled again afterward.

But while the save operation is waiting, the UI thread is not blocked in the same way.

This is one of the places where async and await are especially useful. They make it possible to write responsive UI code without turning the whole operation into a chain of callbacks.

There is one important exception to the usual rule about async void.

In most application code, async void should be avoided because it is harder to await, test, and handle errors from. But UI event handlers are one of the normal places where async void is acceptable, because the event-handler signature is defined by the UI framework.

For example:

private async void RefreshButton_Click(object sender, EventArgs e)
{
    await RefreshArticlesAsync();
}

The event handler may be async void, but the real work can still be placed in an awaitable method:

private async Task RefreshArticlesAsync()
{
    Articles = await _articleService.GetPublishedArticlesAsync(
        CancellationToken.None);
}

That keeps the event handler thin and makes the underlying operation easier to test or reuse.

A useful pattern is:

  1. Let the UI event handler start the operation.
  2. Keep the event handler small.
  3. Put the real work in an async method that returns Task.
  4. Await service or repository calls.
  5. Update the UI after the awaited operation completes.
  6. Handle errors in a way the user can understand.

For example:

private async void RefreshButton_Click(object sender, EventArgs e)
{
    RefreshButton.Enabled = false;
    StatusText.Text = "Refreshing articles...";

    try
    {
        Articles = await _articleService.GetPublishedArticlesAsync(
            CancellationToken.None);

        StatusText.Text = "Articles refreshed.";
    }
    catch (Exception ex)
    {
        StatusText.Text = "The articles could not be refreshed.";
        _logger.LogError(ex, "Failed to refresh articles.");
    }
    finally
    {
        RefreshButton.Enabled = true;
    }
}

This is not only about technical correctness. It is also about user experience.

If an operation may take time, the UI should usually communicate that something is happening. Disabling a button, showing progress, displaying a status message, or allowing cancellation can make the application feel more reliable.

Cancellation is especially useful in UI applications.

A search operation, import process, file operation, or remote call may no longer be relevant if the user closes the window, changes the search text, or presses Cancel.

For example:

private CancellationTokenSource? _searchCancellation;

private async Task SearchAsync(string query)
{
    _searchCancellation?.Cancel();
    _searchCancellation = new CancellationTokenSource();

    try
    {
        SearchResults = await _articleService.SearchArticlesAsync(
            query,
            _searchCancellation.Token);
    }
    catch (OperationCanceledException)
    {
        // The previous search was cancelled because a newer one started.
    }
}

This kind of pattern prevents outdated work from continuing unnecessarily.

It also avoids a common UI problem: older results arriving after newer results and replacing them on the screen.

Async code in UI applications should also avoid blocking on tasks.

This is risky:

var articles = _articleService.GetPublishedArticlesAsync(ct).Result;

Blocking on async work from the UI thread can freeze the interface and, depending on the framework and context, may contribute to deadlocks.

The better approach is usually to await the operation:

var articles = await _articleService.GetPublishedArticlesAsync(ct);

A useful distinction is:

In UI code, async is mainly about keeping the interface responsive while work is waiting.

That does not mean every UI method should be asynchronous.

Formatting a label, enabling a button based on already-loaded state, mapping a small view model, or checking a simple property should normally remain synchronous.

For example:

PublishButton.IsEnabled = ViewModel.CanPublish;

There is no reason to make that asynchronous if CanPublish is already available.

But when the UI starts an operation that may wait, such as saving, loading, searching, importing, exporting, sending, or calling a service, async is often the right choice.

The UI should stay responsive, the operation should be awaited, and the waiting should be visible in the code.

6. Async in web applications

In web applications, async is mainly about scalability and efficient use of server resources.

A web request often spends much of its time waiting. It may wait for a database query, a file operation, an external API, an email service, a payment provider, or another network call.

If that waiting is done synchronously, the request thread is blocked until the operation completes.

For a single request, that may not seem important. But under load, many blocked request threads can become a scalability problem. The server may spend too many threads waiting for I/O instead of using them to process other work.

Asynchronous I/O helps avoid that.

For example, a Razor Page handler might look like this:

public async Task<IActionResult> OnGetAsync(string slug,
    CancellationToken ct)
{
    var article = await _articleStore.GetBySlugAsync(slug, ct);

    if (article is null || !article.IsPublished)
    {
        return NotFound();
    }

    Article = article;

    return Page();
}

The handler still reads like normal step-by-step code. It loads the article, checks whether it can be shown, assigns it to the page model, and returns the page.

But while the article store is waiting for I/O, the request thread does not need to remain blocked in the same way.

The same applies to update operations:

public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    var result = await _articleService.UpdateArticleAsync(Input, ct);

    if (!result.Succeeded)
    {
        ModelState.AddModelError(string.Empty, result.ErrorMessage);
        return Page();
    }

    return RedirectToPage("/Admin/Articles");
}

The handler coordinates the web request, but the waiting work is performed asynchronously.

This is especially useful when the operation involves:

  1. Database access.
  2. File access.
  3. Email delivery.
  4. External APIs.
  5. Payment providers.
  6. Cloud storage.
  7. Search services.
  8. Message queues.

The principle is similar to UI applications, but the reason is different.

In a UI application, async helps keep the interface responsive.

In a web application, async helps avoid tying up server threads while requests wait for I/O.

A useful distinction is:

In web applications, async is less about making one request faster and more about allowing the server to handle waiting more efficiently.

This distinction matters because async does not remove the cost of the operation itself.

If a database query takes 200 milliseconds, making the call asynchronous does not magically make the database return in 20 milliseconds. The query still takes the time it takes.

What changes is that the server does not need to dedicate a blocked thread to that waiting period.

This is why async is most valuable in web code when the dependencies are also truly asynchronous.

For example, this is useful:

var article = await _dbContext.Articles
    .FirstOrDefaultAsync(article => article.Slug == slug, ct);

This is also useful:

await _emailSender.SendAsync(message, ct);

But this is not very useful:

public async Task<int> CountWordsAsync(string content)
{
    return content
        .Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}

The method does immediate CPU work and does not await anything. In a web application, making this method async does not improve scalability.

Another mistake is using Task.Run inside request handling to make synchronous work look asynchronous:

public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
    var result = await Task.Run(() => GenerateReport(), ct);

    return File(result.Content, result.ContentType);
}

This may move work to another thread, but the server still uses a thread to perform the CPU work. It may also add scheduling overhead and make load behavior harder to predict.

There are cases where background processing is appropriate, but then the better design is often to move the work out of the request path entirely. The request can enqueue a job and return a response while the work continues elsewhere.

For example:

public async Task<IActionResult> OnPostGenerateReportAsync(
    CancellationToken ct)
{
    var jobId = await _reportJobQueue.EnqueueAsync(Input.ReportId, ct);

    return RedirectToPage("/Reports/Status", new { jobId });
}

That is different from simply wrapping CPU-heavy work in Task.Run.

async in web applications should also respect cancellation.

ASP.NET Core can provide a cancellation token that is triggered when the client disconnects or the request is aborted. Passing that token down to repositories and services allows unnecessary work to stop earlier.

For example:

public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{
    var result = await _articleService.SaveAsync(Input, ct);

    if (!result.Succeeded)
    {
        return Page();
    }

    return RedirectToPage("/Admin/Articles");
}

and then:

public async Task<SaveArticleResult> SaveAsync(
    SaveArticleRequest request,
    CancellationToken ct)
{
    var article = await _articleRepository.GetByIdAsync(request.Id, ct);

    if (article is null)
    {
        return SaveArticleResult.Failed("Article was not found.");
    }

    article.UpdateContent(
        request.Title,
        request.ContentMarkdown,
        _clock.UtcNow);

    await _articleRepository.SaveAsync(article, ct);

    return SaveArticleResult.Success(article.Id);
}

The token follows the operation through the call chain. If the request is cancelled, the lower layers have a chance to stop waiting work as well.

A useful guideline is:

In web applications, pass the cancellation token as far as the meaningful waiting operation goes.

That does not mean every small helper method needs a cancellation token. A method that trims a string or maps a small object does not need one. But methods that wait for I/O should usually accept and pass one.

async web code should also avoid blocking on tasks:

var article = _articleStore.GetBySlugAsync(slug, ct).Result;

or:

var article = _articleStore.GetBySlugAsync(slug, ct)
    .GetAwaiter()
    .GetResult();

This defeats the purpose of using asynchronous APIs and can contribute to thread-pool starvation under load.

The better approach is to keep the request path asynchronous:

var article = await _articleStore.GetBySlugAsync(slug, ct);

Good async web code usually has a simple shape:

  1. The page handler or controller action is async.
  2. It awaits application services.
  3. Application services await repositories or external services.
  4. Cancellation tokens are passed through meaningful waiting operations.
  5. CPU-heavy work is not hidden behind Task.Run without a deliberate reason.
  6. The request path avoids blocking on async results.

This keeps the web layer honest about waiting.

The code does not become faster just because it is async.

But the server becomes better able to handle many requests that spend time waiting.

7. Async all the way down

One practical consequence of using async is that it tends to move through the call chain.

If a low-level operation is asynchronous, the method that calls it usually needs to become asynchronous too. Then the method above that may also need to become asynchronous.

This is sometimes described as:

Async all the way down.

At first, that can feel inconvenient. A single repository method becomes async, then the application service becomes async, then the page handler, controller action, or UI event handler becomes async.

But this is usually the right direction.

The alternative is often to block somewhere in the middle:

public Article? GetArticle(string id)
{
    return _articleRepository
        .GetByIdAsync(id, CancellationToken.None)
        .GetAwaiter()
        .GetResult();
}

This hides the asynchronous nature of the operation behind a synchronous method. The caller may think this is ordinary immediate work, but the method is actually waiting for I/O and blocking while it does so.

That makes the code less honest.

A clearer design is to let the asynchronous operation remain visible:

public async Task<Article?> GetArticleAsync(
    string id, CancellationToken ct)
{
    return await _articleRepository.GetByIdAsync(id, ct);
}

Then the application service can also be asynchronous:

public async Task<ArticleDetailsResult> GetArticleDetailsAsync(
    string id, CancellationToken ct)
{
    var article = await _articleRepository.GetByIdAsync(id, ct);

    if (article is null)
    {
        return ArticleDetailsResult.NotFound();
    }

    return ArticleDetailsResult.Success(article);
}

And the web page or UI layer can await that service:

public async Task<IActionResult> OnGetAsync(
    string id, CancellationToken ct)
{
    var result = await _articleService.GetArticleDetailsAsync(id, ct);

    if (!result.Succeeded)
    {
        return NotFound();
    }

    Article = result.Article;

    return Page();
}

This may look like async has spread through several layers, and it has. But it has done so for a reason.

The operation begins in the web layer, passes through the application layer, and eventually waits in the repository. Each layer is honest about the fact that the operation may not complete immediately.

That is usually better than hiding the wait at one layer and blocking the thread.

The same principle applies to save operations:

public async Task<SaveArticleResult> SaveArticleAsync(
    SaveArticleRequest request, CancellationToken ct)
{
    var article = await _articleRepository.GetByIdAsync(request.Id, ct);

    if (article is null)
    {
        return SaveArticleResult.Failed("Article was not found.");
    }

    article.UpdateContent(
        request.Title,
        request.ContentMarkdown,
        _clock.UtcNow);

    await _articleRepository.SaveAsync(article, ct);

    return SaveArticleResult.Success(article.Id);
}

The method is not asynchronous because every line is asynchronous. Most of the logic is ordinary synchronous domain logic.

It is asynchronous because part of the use case waits for I/O.

This is an important distinction.

A method can contain both synchronous and asynchronous work. It may load data asynchronously, make domain decisions synchronously, and save data asynchronously.

For example:

var article = await _articleRepository.GetByIdAsync(request.Id, ct);

article.UpdateContent(
    request.Title,
    request.ContentMarkdown,
    _clock.UtcNow);

await _articleRepository.SaveAsync(article, ct);

The domain operation does not need to be async just because it is called from an async method. UpdateContent is immediate in-memory work, so it can remain synchronous.

This keeps the boundary clean:

Repository calls may be asynchronous because they wait for storage.

Application services may be asynchronous because they coordinate asynchronous dependencies.

Domain methods can remain synchronous when they only work with already-loaded state.

UI and web entry points can be asynchronous because they await the use case.

That is a healthier interpretation of “async all the way down.”

It does not mean every method in the system becomes async.

It means async should travel through the call chain until it reaches a natural entry point that can await it.

In a web application, that entry point may be a page handler or controller action:

public async Task<IActionResult> OnPostAsync(CancellationToken ct)

In a UI application, it may be an event handler:

private async void SaveButton_Click(object sender, EventArgs e)

In a background worker, it may be the worker loop:

protected override async Task ExecuteAsync(CancellationToken ct)

The important thing is to avoid creating a synchronous wrapper around asynchronous work unless there is a very deliberate reason.

A useful guideline is:

Do not block on async work just to keep a synchronous method signature.

Sometimes, however, the shape of an interface has to be chosen carefully.

If an interface represents an operation that may involve I/O, it should usually be asynchronous:

public interface IArticleRepository
{
    Task<Article?> GetByIdAsync(string id, CancellationToken ct);

    Task SaveAsync(Article article, CancellationToken ct);
}

If an interface represents pure domain logic, it should usually remain synchronous:

public interface IArticlePublishingPolicy
{
    bool CanPublish(Article article);
}

Making the policy async would only be appropriate if the policy itself needs to wait for something external.

For example, this may justify async:

public interface IArticlePublishingPolicy
{
    Task<bool> CanPublishAsync(
        Article article,
        UserId userId,
        CancellationToken ct);
}

if the policy must query permissions, workflow state, or another external source.

The design decision should come from what the abstraction represents.

Not from a desire to make all interfaces look the same.

A common mistake is mixing sync and async versions without a clear reason:

Article? GetById(string id);

Task<Article?> GetByIdAsync(string id, CancellationToken ct);

Sometimes both are needed, but often one of them becomes a trap. If the operation is fundamentally I/O-bound, the synchronous version may encourage blocking. If the operation is fundamentally in-memory, the asynchronous version may add noise.

The better question is:

Should this abstraction represent waiting?

If yes, make it async and let callers await it.

If no, keep it synchronous.

Async all the way down is not about spreading async everywhere. It is about refusing to hide waiting behind synchronous code.

When asynchronous boundaries are honest, the code is usually easier to reason about. The caller can see that the operation may wait. Cancellation can be passed through. Errors flow through awaited tasks. UI and web entry points can remain responsive and scalable.

The result is not that every method becomes async.

The result is that waiting becomes visible where it matters.

8. Common mistakes

async and await make asynchronous code easier to write, but they do not remove the need for judgment.

Many async problems do not come from the feature itself. They come from using it in ways that hide waiting, add unnecessary complexity, or make control flow harder to understand.

One common mistake is blocking on asynchronous work.

For example:

var article = _articleRepository.GetByIdAsync(id, ct).Result;

or:

var article = _articleRepository
    .GetByIdAsync(id, ct)
    .GetAwaiter()
    .GetResult();

This turns asynchronous work back into a blocking wait.

It may seem convenient when a synchronous method needs the result of an async operation, but it defeats much of the purpose of async code. In UI applications, it can freeze the interface. In server applications, it can contribute to thread-pool pressure. In some contexts, it can also cause deadlock-like behavior.

The better solution is usually to let the calling method become async too:

var article = await _articleRepository.GetByIdAsync(id, ct);

A useful rule is:

If you call async code, prefer awaiting it over blocking on it.

Another common mistake is using async void outside event handlers.

For example:

public async void SaveArticle()
{
    await _articleRepository.SaveAsync(_article, CancellationToken.None);
}

This is problematic because the caller cannot await the method. Exceptions are harder to observe, tests are harder to write, and the operation becomes more difficult to coordinate.

A better version returns Task:

public async Task SaveArticleAsync(CancellationToken ct)
{
    await _articleRepository.SaveAsync(_article, ct);
}

UI event handlers are the main exception, because the framework often requires a void signature:

private async void SaveButton_Click(object sender, EventArgs e)
{
    await SaveArticleAsync(CancellationToken.None);
}

Even there, it is often better to keep the event handler thin and move the real work into a Task-returning method.

Another mistake is adding async without await.

For example:

public async Task<int> CountWordsAsync(string text)
{
    return text
        .Split(' ', StringSplitOptions.RemoveEmptyEntries)
        .Length;
}

This method does not await anything. It does immediate work and returns the result. Making it async adds noise and usually produces a compiler warning.

A synchronous method is clearer:

public int CountWords(string text)
{
    return text
        .Split(' ', StringSplitOptions.RemoveEmptyEntries)
        .Length;
}

If a method must match an asynchronous interface, returning a completed task may be appropriate:

public Task<int> CountWordsAsync(string text)
{
    var count = CountWords(text);

    return Task.FromResult(count);
}

But that should normally be done because of the abstraction, not because the operation itself is truly asynchronous.

Another common mistake is using Task.Run as a general solution.

For example:

public async Task<ReportResult> GenerateReportAsync(
    ReportRequest request,
    CancellationToken ct)
{
    return await Task.Run(() => GenerateReport(request), ct);
}

This may be useful in some UI applications if expensive CPU work must be moved away from the UI thread.

But it is not the same as asynchronous I/O. The CPU work still happens. It is simply scheduled on another thread.

In server-side code, this is often not helpful. The server still needs a thread to perform the work, and adding Task.Run may create more overhead rather than less.

A better approach depends on the situation. If the work is CPU-heavy and long-running, it may belong in a background job. If the work is fast, it may be fine as a synchronous method. If the work is I/O-bound, use true asynchronous APIs instead.

Another mistake is ignoring cancellation tokens.

For example:

public async Task<Article?> GetArticleAsync(string id)
{
    return await _articleRepository.GetByIdAsync(
        id, CancellationToken.None);
}

This makes it impossible for the caller to cancel the operation meaningfully.

A better version accepts and passes the token:

public async Task<Article?> GetArticleAsync(
    string id, CancellationToken ct)
{
    return await _articleRepository.GetByIdAsync(id, ct);
}

Not every method needs a cancellation token. A small synchronous helper does not. But async methods that wait for I/O should usually accept one and pass it to the operations they await.

Another mistake is catching exceptions too early or too broadly.

For example:

public async Task<SaveArticleResult> SaveAsync(
    SaveArticleRequest request, CancellationToken ct)
{
    try
    {
        await _articleRepository.SaveAsync(request.Article, ct);

        return SaveArticleResult.Success();
    }
    catch
    {
        return SaveArticleResult.Failed("Something went wrong.");
    }
}

This hides all details, including possible programming errors, cancellation, and infrastructure failures. It may also make diagnostics harder.

A better approach is to catch exceptions where the code can add useful meaning, and to let unexpected failures be logged or handled at an appropriate boundary:

public async Task<SaveArticleResult> SaveAsync(
    SaveArticleRequest request, CancellationToken ct)
{
    try
    {
        await _articleRepository.SaveAsync(request.Article, ct);

        return SaveArticleResult.Success();
    }
    catch (StorageUnavailableException ex)
    {
        _logger.LogWarning(ex, "Article storage was unavailable.");

        return SaveArticleResult.Failed(
            "The article could not be saved right now.");
    }
}

Cancellation should also be treated carefully. An OperationCanceledException often means the caller no longer wants the result. It should not always be treated as an ordinary error.

Another mistake is forgetting that code after await continues later.

For example:

_isSaving = true;

await _articleRepository.SaveAsync(article, ct);

_isSaving = false;

If the awaited operation throws, _isSaving is never reset.

A safer version uses try / finally:

_isSaving = true;

try
{
    await _articleRepository.SaveAsync(article, ct);
}
finally
{
    _isSaving = false;
}

This is especially important in UI code, where buttons, progress indicators, and busy flags must be restored even when an operation fails.

Another mistake is starting asynchronous work without awaiting or otherwise tracking it.

For example:

_emailSender.SendAsync(message, ct);

The returned task is ignored. If the operation fails, the exception may go unnoticed. The caller also has no way to know when the operation completed.

A better version is usually:

await _emailSender.SendAsync(message, ct);

There are cases where fire-and-forget behavior is intentional, but then it should be designed explicitly. For example, the work may be placed on a background queue that handles errors and retries.

The problem is not background work. The problem is accidental unobserved work.

Another mistake is making domain logic async when it does not need to be.

For example:

public Task PublishAsync(DateTime publishedUtc)
{
    if (string.IsNullOrWhiteSpace(Title))
    {
        throw new InvalidArticleStateException("Title is required.");
    }

    IsPublished = true;
    PublishedUtc = publishedUtc;

    return Task.CompletedTask;
}

This method does not wait for anything. It changes in-memory state. A synchronous method is clearer:

public void Publish(DateTime publishedUtc)
{
    if (string.IsNullOrWhiteSpace(Title))
    {
        throw new InvalidArticleStateException("Title is required.");
    }

    IsPublished = true;
    PublishedUtc = publishedUtc;
}

The application service that loads and saves the article may be async, but the domain operation itself can remain synchronous.

That distinction keeps the model cleaner.

Common async mistakes often have the same underlying cause: the code no longer clearly expresses what is waiting, what is immediate, and who is responsible for handling the result.

A useful checklist is:

  1. Do not block on async work with .Result or .GetAwaiter().GetResult().
  2. Avoid async void except for event handlers.
  3. Do not mark methods async when there is nothing to await.
  4. Do not use Task.Run as a substitute for true asynchronous I/O.
  5. Pass cancellation tokens through meaningful waiting operations.
  6. Do not ignore returned tasks unless the background behavior is deliberate.
  7. Use try / finally when state must be restored after an awaited operation.
  8. Keep domain logic synchronous when it only works with in-memory state.
  9. Catch exceptions where useful meaning can be added.
  10. Let async code remain visible through the call chain instead of hiding it behind blocking wrappers.

async and await work best when they make waiting explicit.

Most mistakes happen when they are used to hide waiting, fake waiting, or ignore the result of waiting.

9. Relation to architecture and testability

async and await are implementation features, but they also influence architecture.

Once a boundary becomes asynchronous, that decision affects interfaces, services, tests, cancellation, error handling, and the way layers communicate. This is why async should not be added casually, but it should also not be hidden when the operation genuinely may wait.

A repository is a good example.

If a repository represents persistence, it often makes sense for its operations to be asynchronous:

public interface IArticleRepository
{
    Task<Article?> GetByIdAsync(string id, CancellationToken ct);

    Task SaveAsync(Article article, CancellationToken ct);
}

The interface does not say whether the current implementation uses a database, JSON files, an API, or something else. It says that retrieving and saving articles may involve waiting.

That is useful architectural information.

The application layer can then coordinate the use case without knowing the persistence details:

public async Task<SaveArticleResult> SaveArticleAsync(
    SaveArticleRequest request,
    CancellationToken ct)
{
    var article = await _articleRepository.GetByIdAsync(request.Id, ct);

    if (article is null)
    {
        return SaveArticleResult.Failed("Article was not found.");
    }

    article.UpdateContent(
        request.Title,
        request.ContentMarkdown,
        _clock.UtcNow);

    await _articleRepository.SaveAsync(article, ct);

    return SaveArticleResult.Success(article.Id);
}

The application service is asynchronous because it coordinates asynchronous dependencies. But the domain operation remains synchronous:

article.UpdateContent(
    request.Title,
    request.ContentMarkdown,
    _clock.UtcNow);

That distinction is important.

Architecture becomes clearer when each layer uses async for the right reason:

The web or UI layer may be async because it starts operations that must not block the caller.

The application layer may be async because it coordinates repositories and external services.

The infrastructure layer may be async because it performs I/O.

The domain model usually remains synchronous when it only protects in-memory business state.

This keeps the design honest.

The repository does not pretend that persistence is immediate. The application layer does not pretend that the use case completes without waiting. The domain model does not pretend that simple state changes require asynchronous control flow.

async also affects testability.

A good async boundary can make code easier to test because the waiting behavior is explicit. Tests can await application services just like production code does:

[Fact]
public async Task
SaveArticleAsync_ReturnsNotFound_WhenArticleDoesNotExist()
{
    var repository = new FakeArticleRepository();
    var service = new ArticleService(repository, TestClock.Fixed());

    var result = await service.SaveArticleAsync(
        new SaveArticleRequest
        {
            Id = "missing",
            Title = "New title",
            ContentMarkdown = "Content"
        },
        CancellationToken.None);

    Assert.False(result.Succeeded);
}

This test does not need to block on .Result. It does not need special synchronization logic. It simply awaits the use case.

Fake implementations can still be simple:

public Task<Article?> GetByIdAsync(string id, CancellationToken ct)
{
    var article = _articles.FirstOrDefault(article => article.Id == id);

    return Task.FromResult(article);
}

This is acceptable when the fake implements an abstraction that is asynchronous for good architectural reasons. The fake completes immediately, while the production implementation may wait for storage.

That is different from making the domain model asynchronous just to make tests look consistent.

For example, this is usually unnecessary:

public Task PublishAsync(DateTime publishedUtc)
{
    Publish(publishedUtc);

    return Task.CompletedTask;
}

If publishing is an in-memory domain operation, tests should be able to call it directly:

article.Publish(publishedUtc);

This makes the domain model easier to test and easier to understand.

async tests also need to avoid a common mistake: blocking on the result of the method under test.

This is less desirable:

var result = service
    .SaveArticleAsync(request, CancellationToken.None)
    .Result;

The test should normally be async too:

var result = await service.SaveArticleAsync(
    request, CancellationToken.None);

Most modern .NET test frameworks support async test methods, so there is usually no reason to block.

Cancellation can also be tested when it matters.

For example, if a service passes a cancellation token to its repository, a test can verify that behavior:

[Fact]
public async Task SearchArticlesAsync_PassesCancellationToken_ToRepository()
{
    using var cts = new CancellationTokenSource();

    var repository = new RecordingArticleRepository();
    var service = new ArticleSearchService(repository);

    await service.SearchArticlesAsync("architecture", cts.Token);

    Assert.Equal(cts.Token, repository.LastCancellationToken);
}

Not every cancellation path needs detailed tests, but cancellation should be treated as part of the contract when operations may wait.

async can also clarify architectural boundaries around external services.

For example:

public interface IEmailSender
{
    Task SendAsync(EmailMessage message, CancellationToken ct);
}

This tells the application layer that sending email may wait and may fail. The application service can decide whether email delivery is part of the main use case, whether failures should be reported to the user, or whether the message should be queued for background processing.

That architectural decision matters more than the presence of the async keyword itself.

For example, these two designs communicate different things:

await _emailSender.SendAsync(message, ct);

and:

await _emailQueue.EnqueueAsync(message, ct);

The first says the use case waits for the email operation. The second says the use case queues the work and lets something else send it later.

Both may be valid, but they describe different behavior.

async makes those decisions visible when used well.

It also makes poor boundaries visible.

If a domain entity suddenly needs to call an async repository, that is often a warning sign. Domain objects should usually not depend directly on persistence or external services. If a domain method becomes async because it needs to fetch data, the design may be mixing domain behavior with infrastructure access.

For example, this would be suspicious:

public async Task<bool> CanPublishAsync(
    IArticleRepository repository, CancellationToken ct)
{
    var relatedArticles =
        await repository.GetRelatedArticlesAsync(Id, ct);

    return relatedArticles.All(article => article.IsPublished);
}

The problem is not only that the method is async. The deeper problem is that the domain object now knows about the repository.

A better design may load the required data before calling the domain operation, or place the rule in a policy or domain service:

public interface IArticlePublishingPolicy
{
    Task<bool> CanPublishAsync(
        Article article,
        CancellationToken ct);
}

or, if all required state is already loaded:

public bool CanPublish(IReadOnlyList<Article> relatedArticles)
{
    return relatedArticles.All(article => article.IsPublished);
}

The right choice depends on the rule, but async should not be used to hide a dependency problem.

A useful distinction is:

Async should express waiting. It should not be used to smuggle infrastructure dependencies into places they do not belong.

From a testability perspective, this matters because code that mixes domain logic and asynchronous infrastructure is harder to test. It needs mocks, tasks, cancellation tokens, and external behavior just to verify a business rule.

Keeping domain logic synchronous when possible makes it easier to test directly.

Keeping application services async when they coordinate I/O makes them honest and testable at the use-case level.

Keeping infrastructure async when it performs I/O makes waiting explicit.

This leads to a practical architectural guideline:

Use async at boundaries that may wait. Keep immediate business decisions synchronous unless they truly depend on asynchronous data.

That guideline keeps both the architecture and the tests cleaner.

10. Practical guidelines

async and await are most useful when they make waiting explicit.

They should not be treated as a general style preference. They are part of the method’s contract. A method that returns Task tells its caller that the operation may not complete immediately, may need to be awaited, may fail later, and may support cancellation.

That means the decision to use async should be deliberate.

A useful first question is:

Does this method represent work that may need to wait?

If the answer is yes, async is often appropriate.

If the answer is no, synchronous code is often clearer.

For example, this should usually be async:

public Task<Article?> GetByIdAsync(string id, CancellationToken ct);

because retrieving an article may involve persistence.

This should usually remain synchronous:

public bool CanPublish(Article article);

if the decision only depends on already-loaded in-memory state.

The next question is:

Would blocking the caller be harmful?

In a UI application, blocking may freeze the interface. In a web application, blocking may waste request threads. In a background process, blocking may make cancellation and coordination harder.

If blocking would be harmful and the operation may wait, async is usually a good fit.

Another guideline is to keep async close to I/O.

Methods that read files, write files, query databases, send email, call web APIs, access cloud storage, or communicate with external services should often be asynchronous.

For example:

await _articleRepository.SaveAsync(article, ct);
await _emailSender.SendAsync(message, ct);
await _paymentProvider.CreateCheckoutSessionAsync(request, ct);

These calls may wait for systems outside the current process.

That is exactly where async is useful.

By contrast, formatting, mapping, validation, and domain state changes should usually remain synchronous when they only use data already in memory.

For example:

var summary = ArticleSummary.FromArticle(article);

article.Publish(_clock.UtcNow);

var displayTitle = FormatTitle(article.Title);

There is no need to make those operations asynchronous unless they start depending on something external.

A practical guideline is:

Keep domain logic synchronous unless it truly needs asynchronous data.

If a domain method appears to need async, look carefully at why. It may be a sign that the domain model is reaching into infrastructure or that required data should be loaded before the domain operation is called.

Another guideline is to let async flow through the call chain.

If an application service awaits a repository, the application service should usually be async. If a page handler awaits an application service, the page handler should be async. Avoid blocking in the middle just to keep a synchronous method signature.

Prefer this:

public async Task<SaveArticleResult> SaveArticleAsync(
    SaveArticleRequest request, CancellationToken ct)
{
    var article = await _articleRepository.GetByIdAsync(request.Id, ct);

    if (article is null)
    {
        return SaveArticleResult.Failed("Article was not found.");
    }

    article.Publish(_clock.UtcNow);

    await _articleRepository.SaveAsync(article, ct);

    return SaveArticleResult.Success(article.Id);
}

over this:

public SaveArticleResult SaveArticle(SaveArticleRequest request)
{
    var article = _articleRepository
        .GetByIdAsync(request.Id, CancellationToken.None)
        .GetAwaiter()
        .GetResult();

    if (article is null)
    {
        return SaveArticleResult.Failed("Article was not found.");
    }

    article.Publish(_clock.UtcNow);

    _articleRepository
        .SaveAsync(article, CancellationToken.None)
        .GetAwaiter()
        .GetResult();

    return SaveArticleResult.Success(article.Id);
}

The second version hides waiting behind a synchronous method and blocks while doing so.

Another guideline is to pass cancellation tokens through meaningful waiting operations.

For example:

public async Task<Article?> GetArticleAsync(
    string id, CancellationToken ct)
{
    return await _articleRepository.GetByIdAsync(id, ct);
}

The method accepts the caller’s cancellation token and passes it to the operation that may wait.

This is usually better than:

public async Task<Article?> GetArticleAsync(string id)
{
    return await _articleRepository.GetByIdAsync(
        id,
        CancellationToken.None);
}

Using CancellationToken.None inside a method can be appropriate in some cases, but it should be deliberate. If the caller has a meaningful cancellation token, it should normally be passed along.

Another guideline is to avoid async void, except for UI event handlers.

Most async methods should return Task or Task:

public async Task SaveAsync(CancellationToken ct)
{
    await _articleRepository.SaveAsync(_article, ct);
}

This allows callers to await the method, observe exceptions, and coordinate the operation.

UI event handlers are a normal exception because the framework often requires a void signature:

private async void SaveButton_Click(object sender, EventArgs e)
{
    await SaveAsync(CancellationToken.None);
}

Even then, the event handler should usually delegate to a Task-returning method.

Another guideline is to avoid adding async just to satisfy naming style.

A method should not be called NormalizeAsync, MapAsync, or CanPublishAsync unless the abstraction really may wait.

For immediate work, prefer simple synchronous methods:

public string NormalizeTitle(string title)
{
    return title.Trim();
}

public bool CanPublish(Article article)
{
    return !string.IsNullOrWhiteSpace(article.Title)
           && !string.IsNullOrWhiteSpace(article.ContentMarkdown);
}

Another guideline is to be careful with Task.FromResult.

Task.FromResult is useful when implementing an asynchronous abstraction with an immediate result, such as a fake repository in a test:

public Task<Article?> GetByIdAsync(string id, CancellationToken ct)
{
    return Task.FromResult(_articles.FirstOrDefault(article =>
       article.Id == id));
}

But it should not be used to make a purely synchronous operation look asynchronous without a reason.

Another guideline is to avoid Task.Run as a default escape hatch.

Task.Run may be useful when CPU-heavy work must be moved away from a UI thread. But it is not a substitute for proper asynchronous I/O, and it is often not helpful inside web request handling.

If work is CPU-heavy and long-running, consider whether it belongs in a background job instead of the request path.

Another guideline is to keep error handling explicit.

Exceptions from async methods are observed when the task is awaited. This means error handling should be placed where the code can add useful meaning:

try
{
    await _articleRepository.SaveAsync(article, ct);
}
catch (StorageUnavailableException ex)
{
    _logger.LogWarning(ex, "Article storage was unavailable.");

    return SaveArticleResult.Failed(
        "The article could not be saved right now.");
}

Do not catch everything just to hide failures. Unexpected failures should usually be logged and handled at an appropriate boundary.

Another guideline is to restore state with try / finally when needed.

This matters especially in UI code:

SaveButton.Enabled = false;

try
{
    await _articleService.SaveAsync(request, ct);
}
finally
{
    SaveButton.Enabled = true;
}

Code after await may not run if the awaited operation throws. If cleanup or state restoration must happen, use finally.

A compact checklist can be useful:

  1. Use async for I/O-bound work.
  2. Use async to keep UI applications responsive.
  3. Use async in web request paths that wait for I/O.
  4. Pass cancellation tokens through meaningful waiting operations.
  5. Let async flow through the call chain instead of blocking on it.
  6. Avoid async void except for UI event handlers.
  7. Keep domain logic synchronous when it only uses in-memory state.
  8. Avoid Task.Run unless moving CPU work is a deliberate design choice.
  9. Avoid Task.FromResult unless adapting to an async abstraction.
  10. Do not make methods async only because other nearby methods are async.
  11. Await returned tasks unless background work is explicitly designed.
  12. Use try / finally when awaited operations must restore state afterward.

These guidelines are not absolute rules.

There are exceptions in real systems. Interfaces sometimes need asynchronous shapes because future implementations may wait. Test doubles may return completed tasks. UI event handlers may be async void. Some CPU-heavy work may need to be moved off the UI thread.

The important thing is that these choices should be deliberate.

Async code is best when it tells the truth about the operation.

It says:

This may take time, and the caller should not be blocked while it waits.

When that is true, async and await are valuable tools.

When it is not true, synchronous code is often simpler and clearer.

11. Conclusion

async and await are valuable because they let code express waiting without forcing the caller to block unnecessarily.

That is useful in many places: UI applications that must stay responsive, web applications that need to handle many waiting requests, repositories that access storage, services that call external systems, and workflows that need cancellation.

But asynchronous code should still be used deliberately.

The goal is not to make every method return Task. The goal is to make waiting visible where waiting is real.

A method that reads from a database, writes a file, sends an email, or calls an external API is a natural candidate for async. A method that trims a string, maps an object, validates already-loaded state, or updates a domain object in memory usually is not.

That distinction keeps the code easier to understand.

Async also affects architecture. Once a boundary becomes asynchronous, that choice tends to move upward through the call chain. That is usually better than hiding the wait behind .Result, .GetAwaiter().GetResult(), or a synchronous wrapper.

At the same time, async should not be allowed to spread into places where it adds no value. Domain logic, simple policies, formatting, mapping, and in-memory decisions can often remain synchronous even when the application service around them is asynchronous.

A good async design is honest about the difference between waiting and immediate work.

The repository may be async because storage may wait. The application service may be async because it coordinates storage and external services. The web page or UI event handler may be async because it awaits the use case. But the domain object can still expose a simple synchronous method when it only changes already-loaded state.

That kind of separation makes the code easier to test, easier to reason about, and easier to maintain.

async and await are not just syntax. They are part of the design of a method’s contract.

Used well, they make waiting explicit.

Used casually, they can add noise, hide blocking, or spread unnecessary complexity.

The practical question is therefore simple:

Does this operation need to wait in a way that should not block the caller?

If the answer is yes, async is probably a good fit.

If the answer is no, synchronous code may be the clearer design.