MediumAsync/Await Does Not Mean Background Processing: The Misconception Delaying Your APIs | by Vahid Faraji | Medium
Sitemap
Vahid Faraji

Vahid Faraji

.NET Software Engineer | C# | Azure CI/CD | TDD | Unit & Integration Testing

Async/Await Does Not Mean Background Processing: The Misconception Delaying Your APIs

9 min readSep 13, 2026

--

One of the most common misunderstandings in the world of backend development is confusing asynchronous I/O with Background Processing.

Some developers write heavy tasks such as calling AI, sending emails, or generating PDF files using async/await and expect the API to respond immediately while the task is performed in the background. However, when they test it, they see that the HTTP request still waits for several seconds!

Why does this happen? Doesn’t "async" mean asynchronous?

Press enter or click to view image in full size

1. The Main Difference: Thread Liberation vs. Response Liberation

To understand this, we need to separate two distinct concepts:

  1. Server Resource Management (Thread Utilization)
  2. User Request Execution Flow (Execution Flow)

When we write code like this:

await aiService.AnalyzeMessageAsync(lead.Message);

A) From the Thread and CPU Perspective (Reducing Server Occupancy)

The await keyword tells the system Runtime:

“I have started a network (I/O) operation with Azure OpenAI that takes 5 seconds. During these 5 seconds, do not keep the current thread waiting; release it so it can process requests from other users. When the network response comes back, bring another thread to continue executing the code.”

This is called non-blocking I/O. The purpose of this is increasing server throughput (scalability) so it can respond to more concurrent users, not reducing the waiting time for this particular user.

B) From the User and Browser Perspective (Response)

Although the server thread has been released, the code after the await will not execute until the Azure response comes back. As a result, the return OK() instruction is not executed, and the HTTP response does not reach the user/browser.

The user still has to wait 5 seconds.

2. The Restaurant Example (An Intuitive View)

Imagine a restaurant operating in 4 different modes:

  • Mode 1: Synchronous (Blocking)

The waiter takes your pizza order, goes to the kitchen, and stands in front of the oven for 15 minutes waiting for the pizza to bake. During this time, they do not serve any other customer.

(Very poor server efficiency).

  • Mode 2: Async with await (non-blocking)

The waiter gives the order to the kitchen and, during the 15 minutes while the pizza is baking, goes and serves 20 other customers. However, you are still sitting in your chair and cannot leave the restaurant until the pizza arrives at your table.

(The server is managed very efficiently, but the user’s waiting time has not changed).

  • Mode 3: Task. Run / Fire-and-Forget

The waiter gives your order to the kitchen, immediately tells you that your pizza is ready to be prepared, and lets you leave. However, the waiter does not give the kitchen a reliable ticket or tracking system.

The waiter tells the kitchen to prepare your pizza and immediately lets you leave. However, there is no reliable system to make sure the order continues to be processed.

If the restaurant suddenly closes or the kitchen stops working, your order may simply disappear. You already left the restaurant believing that everything was handled successfully.

(Fast response, but unreliable background processing and possible data loss).

  • Mode 4: Background Task / Queue (True Background Processing)

You place your order, and the waiter immediately gives you a receipt and says, “Your order has been registered. You can leave. When it is ready, we will send it with a delivery driver.”

You leave the restaurant in under 5 seconds (reduced user latency). The kitchen continues working in the background, and the order is tracked so that it can be retried or recovered if something goes wrong.

The difference from Mode 3 is that this time, the waiter does not simply tell the kitchen to prepare the pizza and then forget about it. The order is officially registered, so the restaurant knows that the pizza still needs to be prepared. If the kitchen has a problem, another worker can pick up the order and prepare it later.

(Fast response, reliable background processing, and proper failure handling).

3. Example with C# / .NET — Code Review

Suppose we want to register a new Lead in a CRM system and send its text to Azure OpenAI for sentiment analysis.

Pattern One:

// Synchronous Blocking (Worst case for server Scalability)
[HttpPost]
public IActionResult CreateLead([FromBody] CreateLeadDto dto)
{
var lead = new Lead { Message = dto.Message };
// 1. Blocking the
// Thread for saving to the database
_dbContext.Leads.Add(lead);
_dbContext.SaveChanges();
// 2. ❌
// Completely blocking the server Thread for 3 to
// 10 seconds for AI
// The method executes in Blocking mode
// and the Thread waits doing nothing
var aiResult = _aiService.Analyze(lead.Message);
lead.Sentiment = aiResult.Sentiment;
_dbContext.SaveChanges();
return Ok(lead);
// User waits + the server Thread was also locked!
}

This is the most basic and vulnerable implementation approach in web programming. In this code, all methods such as SaveChanges() or Analyze() are called synchronously (without the await keyword). When the client sends a request to this API, the following happens:

1. How the Server Thread Behaves (Complete Lock)

When the HTTP request enters the server, the server’s thread pool assigns a dedicated thread (for example, Thread #12) to this request.

When the code reaches _aiService.Analyze(...), the network call to the AI service begins. This call takes, for example, 5 seconds.

During all of these 5 seconds, Thread #12 becomes completely locked (blocked), meaning this thread sits idle on the server waiting and cannot do anything else.

2. What Happens to Server Scalability?

The number of active Threads on each server is limited (for example, 100).

If 100 users simultaneously send requests to this endpoint, 100 server threads become locked.

Get Vahid Faraji’s stories in your inbox

Join Medium for free to get updates from this writer.

If user 101 sends a simple request at the same moment (such as viewing a text page), the server no longer has a free thread to process their request!

A phenomenon called "thread pool starvation" occurs. The server starts rejecting requests and returns the HTTP 503 Service Unavailable error or completely crashes.

Pattern Two: Async but Still Waiting (Slow Response)

[HttpPost]
public async Task<IActionResult> CreateLead([FromBody] CreateLeadDto dto)
{
var lead = new Lead { Message = dto.Message };
await _dbContext.Leads.AddAsync(lead);
await _dbContext.SaveChangesAsync();
// ❌ This line blocks the HTTP request for 3
// to 10 seconds!
var aiResult = await _aiService.AnalyzeAsync(lead.Message);
lead.Sentiment = aiResult.Sentiment;
await _dbContext.SaveChangesAsync();
return Ok(lead); // User waits for the AI response!
}

Result: Response time = Database save (20 ms) + AI (4000 ms) = approximately 4 seconds of delay!

What is the difference between this code (sync) and the code in the pattern (async with await)?

In both cases, the user waits 3 to 10 seconds. But the difference is what happens behind the scenes on the server:

  1. In the Sync code above:

The server thread that received the request, for example, Thread #1, is completely blocked for 10 seconds and cannot process other requests during that time. If 100 users send requests simultaneously, 100 server threads become blocked, potentially leading to thread pool starvation. As a result, new requests may have to wait for an available thread and can eventually fail with a 503 Service Unavailable error or a timeout.

2. In the async code():

Thread number 1 is released during those 10 seconds so that it can respond to other users. The server does not crash, and other users can continue working, but this particular user still has to wait the same 10 seconds for the response.

The thread handling the request is released during those 10 seconds, allowing it to process other requests. When the AI response arrives, a thread from the ThreadPool can resume the execution. The server can continue processing other users, but this particular user still has to wait the same 10 seconds for the response.

In ASP.NET Core / .NET, when execution reaches await, the current thread is released. Once the I/O operation completes, the continuation of the method runs on a ThreadPool thread. This thread can be

  • The same thread as before, or
  • A different thread from the ThreadPool.

Pattern Three: Dangerous Mistake — Task.Run (Fire-and-Forget)

Some developers remove await to escape the high delay:

// ❌ This is dangerous in Production!
[HttpPost]
public async Task<IActionResult> CreateLead([FromBody] CreateLeadDto dto)
{
var lead = new Lead { Message = dto.Message };
await _dbContext.Leads.AddAsync(lead);
await _dbContext.SaveChangesAsync();
// Uncontrolled execution
// in the background
Task.Run(async () => {
var result = await _aiService.AnalyzeAsync(lead.Message);
// Save result...
});
return Ok(lead); // Returns quickly, but...
}

Why is this not a good pattern?

Task.Run starts the work in the background, but the application does not reliably manage that work. The HTTP request returns successfully while the task is still running.

If the API process restarts, or crashes, or the IIS app pool is recycled before the task finishes, the task is terminated and the remaining work is lost. There is no guaranteed retry, persistence, or reliable way to track whether the operation was completed successfully

Comparison of Pattern Two and Three:

(await directly in Controller):

var aiResult = await _aiService.AnalyzeAsync(lead.Message);

In this case, the HTTP connection between the user and the server is open and alive.

  • If the server restarts or crashes: The user’s HTTP connection is terminated, and the browser immediately receives a 502 Bad Gateway or 504 Gateway Timeout or Connection Reset error.
  • Result for the user: The user or client realizes that their request has failed. Therefore, the client can activate its auto-retry mechanism and resend the Lead creation request. “Client” does not necessarily mean the user. It refers to the software that sends the HTTP request to the API and receives the response.

(Task.Run / Fire-and-Forget):

Task.Run(async () => {
var result = await _aiService.AnalyzeAsync(lead.Message);
// ...
});
return Ok(lead); // 👈 This is where the problem is!

In this case, the code immediately returns the 200 OK response to the user, and the HTTP connection is closed.

  • From the user’s perspective: The user receives the message “Lead registered successfully” (HTTP 200) and is reassured that everything went correctly.
  • If the App Pool restarts 1 second later or an Unhandled Exception occurs: The processing inside Task.Run dies and is destroyed without sending any response!
  • Result for the user: The user thinks the AI analysis will be performed, while the processing has evaporated from the server’s RAM! Nobody (neither the client nor the server) knows that this work was abandoned halfway because the server had already told the user, “Everything was OK!”

Summary of the Difference:

  1. (await): If the server crashes, a clear error is returned to the user (User/Client knows it failed).
  2. (Task.Run): The user is lied to; the server says the work was completed (200 OK), but later the background work is completely destroyed without the user having any idea about it (Silent Failure).

Pattern Four: Standard Architecture (Background Worker / Queue)

The correct approach is to completely separate receiving the request from heavy processing:

// 1. The API responds quickly (approximately 15
// milliseconds)
[HttpPost]
public async Task<IActionResult> CreateLead([FromBody] CreateLeadDto dto)
{
var lead = new Lead { Message = dto.Message };
await _dbContext.Leads.AddAsync(lead);
await _dbContext.SaveChangesAsync();
// Putting a
// Job into the background queue (Channel or Queue)
await _backgroundQueue.EnqueueAsync(new
AnalyzeLeadJob(lead.Id));
return Accepted(new { lead.Id, Status = "Processing" });
}

// 2. Processing is performed in a separate Background Service
public class LeadAiWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken
stoppingToken)
{
await foreach (var jobId in
_backgroundQueue.ReadAllAsync(stoppingToken))
{
// All
// possible errors, Retry and the 3
// to 10 second AI processing
// are handled in the background
// without making the user wait.
await ProcessAiAnalysisAsync(jobId);
}
}
}

The LeadAiWorker continuously waits for new jobs in the queue and processes them independently of the original HTTP request. The API can therefore return a response immediately, while the AI analysis continues in the background without keeping the user waiting.

Comparison at a Glance

Press enter or click to view image in full size

Conclusion

  1. async/await is for optimizing server resources: This capability allows your server to respond to more users with fewer threads, but it does not make the method execution time zero.
  2. await prevents the response from being returned: Until an await completes, the response does not return.
  3. Move unnecessary heavy tasks to a queue: If the user does not need to see the result of the work (such as sending an email or performing AI analysis) immediately on the page, completely separate it from the HTTP request flow.

I hope you found this topic interesting and useful.

--

--