🙏🙏Emergency Prayer Circle

Fire and Forget (Your Sanity)

TL;DR: Using async void for background operations turns unhandled exceptions into application-ending surprises

#csharp#async#fire-and-forget#exceptions#async-void

The Code

csharp
1public class OrderService
2{
3    private readonly IEmailService _emailService;
4    private readonly ILogger _logger;
5
6    public OrderService(IEmailService emailService, ILogger logger)
7    {
8        _emailService = emailService;
9        _logger = logger;
10    }
11
12    public void ProcessOrder(Order order)
13    {
14        // Save order to database
15        SaveOrder(order);
16        
17        // Send confirmation email in the background
18        SendConfirmationEmail(order);
19        
20        // Return immediately
21    }
22
23    // ⚠️ The Problem: async void
24    private async void SendConfirmationEmail(Order order)
25    {
26        try
27        {
28            await _emailService.SendAsync(order.Email, "Order Confirmation", 
29                $"Your order #{order.Id} has been received.");
30            _logger.LogInfo($"Email sent for order {order.Id}");
31        }
32        catch (Exception ex)
33        {
34            // Even with try-catch, if anything goes wrong...
35            _logger.LogError($"Failed to send email: {ex.Message}");
36            throw; // ...this crashes the entire application!
37        }
38    }
39
40    private void SaveOrder(Order order)
41    {
42        // Database save logic
43    }
44}
45
46public class ReportController : Controller
47{
48    private readonly IReportService _reportService;
49
50    // Another async void example
51    public IActionResult GenerateReport(int reportId)
52    {
53        // Kick off report generation
54        GenerateReportAsync(reportId);
55        
56        // Return immediately - user sees success!
57        return Ok("Report generation started");
58    }
59
60    private async void GenerateReportAsync(int reportId)
61    {
62        // This could take 5 minutes
63        var data = await _reportService.GetReportDataAsync(reportId);
64        var pdf = await _reportService.GeneratePdfAsync(data);
65        await _reportService.SaveAsync(pdf);
66        
67        // If anything fails here, the application crashes
68        // But the user already got a "success" message!
69    }
70}
71

The Prayer 🙏🙏

🙏🙏 Dear Production Gods, please let these background operations complete successfully. May no exceptions be thrown in our fire-and-forget methods. And if they do fail, may the application crash gracefully without taking down the entire server. We also pray that users won't notice when their "successfully generated" reports never actually appear.

The Reality Check

The async void pattern is a ticking time bomb in your codebase. Unlike async Task, you cannot await an async void method, which means:

  1. Unhandled exceptions crash the application - Even with try-catch blocks, if an exception escapes (or is re-thrown), it bypasses normal exception handling and crashes your entire app process
  2. No way to track completion - You can't await the method, so you have no idea when (or if) it finishes
  3. No way to get results or errors - Fire-and-forget means exactly that - you'll never know what happened
  4. Testing is nearly impossible - Unit tests can't await completion, leading to flaky tests
  5. Race conditions everywhere - Your method returns immediately, but the async work continues, creating subtle timing bugs

In production, this manifests as mysterious application crashes with stack traces that make no sense. The email service times out? Application down. The report generation hits a database deadlock? Application down. A network blip during the async call? Application down. Your monitoring shows crashes with "unhandled exception" but the stack traces don't connect to any request - because the request finished successfully long before the crash happened.

Even worse: users think everything worked because they got success responses immediately. Then they wait... and wait... and their emails never arrive, their reports never generate, and you have no error logs to explain why because the application crashed and restarted.

The Fix

Never use async void except for event handlers. Period. Here's how to fix it:

Fix #1: Return Task and await it

csharp
1public async Task ProcessOrderAsync(Order order)
2{
3    SaveOrder(order);
4    
5    // Now we can await it - errors are handled properly
6    await SendConfirmationEmailAsync(order);
7}
8
9private async Task SendConfirmationEmailAsync(Order order)
10{
11    try
12    {
13        await _emailService.SendAsync(order.Email, "Order Confirmation", 
14            $"Your order #{order.Id} has been received.");
15        _logger.LogInfo($"Email sent for order {order.Id}");
16    }
17    catch (Exception ex)
18    {
19        _logger.LogError($"Failed to send email: {ex.Message}");
20        // Exception is now properly handled - no crash!
21    }
22}
23

Fix #2: If you truly need fire-and-forget, use a safe wrapper

csharp
1public void ProcessOrder(Order order)
2{
3    SaveOrder(order);
4    
5    // Safely fire-and-forget with proper error handling
6    _ = SendConfirmationEmailSafelyAsync(order);
7}
8
9private async Task SendConfirmationEmailSafelyAsync(Order order)
10{
11    try
12    {
13        await SendConfirmationEmailAsync(order);
14    }
15    catch (Exception ex)
16    {
17        // Log but don't throw - this prevents crashes
18        _logger.LogError(ex, $"Background email send failed for order {order.Id}");
19    }
20}
21

Fix #3: Use a proper background job system

csharp
1public void ProcessOrder(Order order)
2{
3    SaveOrder(order);
4    
5    // Queue a background job - proper retry logic, error handling, monitoring
6    _backgroundJobClient.Enqueue(() => 
7        SendConfirmationEmailAsync(order));
8}
9

The key principle: async methods should return Task or Task. This allows:

  • Proper exception propagation
  • Awaitable completion
  • Testable code
  • Explicit error handling decisions

Lesson Learned

The only valid use of async void is event handlers - everywhere else, return Task and let the caller decide whether to await or safely ignore.