Blog Post

Educator Developer Blog
4 MIN READ

Fire-and-Forget Methods in C# — Best Practices & Pitfalls

SyedShahriyar's avatar
SyedShahriyar
Brass Contributor
Nov 25, 2024

When building modern applications, there are often situations where you want to perform tasks in the background without holding up the main flow of your application. This is where “fire-and-forget” methods come into play. In C#, a fire-and-forget method allows you to run a task without awaiting its completion.

A common use case is sending a confirmation email after creating a user, but this also brings some limitations, particularly when dealing with scoped services. In this blog, we’ll walk through an example and explain why scoped services, like database contexts or HTTP clients, cannot be accessed in fire-and-forget methods.

Why Fire-and-Forget?

Fire-and-forget is useful in situations where you don’t need the result of an operation immediately, and it can happen in the background, for example:

  • Sending emails
  • Logging
  • Notification sending

Here’s a common scenario where fire-and-forget comes in handy: sending a welcome email after a user is created in the system.

public async Task<ServiceResponse<User?>> AddUser(User user)
{
    var response = new ServiceResponse<User?>();

    try
    {
        // User creation logic (password hashing, saving to DB, etc.)
        _context.Users.Add(user);
        await _context.SaveChangesAsync();

        // Fire-and-forget task to send an email
        _ = Task.Run(() => SendUserCreationMail(user));

        response.Data = user;
        response.Message = user.FirstName + " added successfully";
    }
    catch (Exception ex)
    {
        // Error handling
    }

    return response;
}

The method SendUserCreationMail sends an email after a user is created, ensuring that the main user creation logic isn’t blocked by the email-sending process.

private async Task SendUserCreationMail(int id)
{
    // This will throw an exception be _context is an scoped service
    var user = await _context.Users.FindAsync(id);
    var applicationUrl = "https://blogs.shahriyarali.com"
    
    string body = $@"
    <body>
        <p>Dear {user.FirstName},</p>
        <p>A new user has been created in the system:</p>
        <p>Username: {user.Username}</p>
        <p>Email: {user.Email}</p>
        <p>Welcome to the system! Please use the provided username and email to log in. You can access the system by clicking on the following link:</p>
        <p><a href='{applicationUrl}'>{applicationUrl}</a></p>
        <p>Best regards,</p>
        <p>Code With Shahri</p>
    </body>";

    var mailParameters = new MailParameters
    {
        Subject = $"New User Created - {user.Username}",
        Body = body,
        UserEmails = new List<UserEmail> { new() { Name = user.FirstName, Email = user.Email } }
    };

    await _mailSender.SendEmail(mailParameters);
}

In the code above, the SendUserCreationMail method is executed using Task.Run(). Since it's a fire-and-forget task, we don’t await it, allowing the user creation process to complete without waiting for the email to be sent.

The Problem with Scoped Services

A major pitfall with fire-and-forget tasks is that you cannot reliably access scoped services (such as DbContext or ILogger) within the task. This is because fire-and-forget tasks continue to run after the HTTP request has been completed, and by that point, scoped services will be disposed of.

For example, if _mailSender was scoped services, they could be disposed of before the SendUserCreationMail task completes, leading to exceptions.

Why Can’t We Access Scoped Services?

Scoped services have a lifecycle tied to the HTTP request in web applications. Once the request ends, these services are disposed of, meaning they are no longer available in any background task that wasn’t awaited within the request lifecycle.

In the example above, since the fire-and-forget email sending isn’t awaited, attempting to use scoped services will throw an ObjectDisposedException.

To safely access scoped services in a fire-and-forget method, you can leverage IServiceScopeFactory to manually create a service scope, ensuring that the services are available for the task.

private async Task SendUserCreationMail(int id)
{
    // Create a service scope.
    using var scope = _serviceScopeFactory.CreateScope();
    var _context = scope.ServiceProvider.GetRequiredService<DataContext>();
    
    var user = await _context.Users.FindAsync(id);
    var applicationUrl = "https://blogs.shahriyarali.com"
    
    string body = $@"
    <body>
        <p>Dear {user.FirstName},</p>
        <p>A new user has been created in the system:</p>
        <p>Username: {user.Username}</p>
        <p>Email: {user.Email}</p>
        <p>Welcome to the system! Please use the provided username and email to log in. You can access the system by clicking on the following link:</p>
        <p><a href='{applicationUrl}'>{applicationUrl}</a></p>
        <p>Best regards,</p>
        <p>Code With Shahri</p>
    </body>";

    var mailParameters = new MailParameters
    {
        Subject = $"New User Created - {user.Username}",
        Body = body,
        UserEmails = new List<UserEmail> { new() { Name = user.FirstName, Email = user.Email } }
    };

    await _mailSender.SendEmail(mailParameters);
}

Conclusion

Fire-and-forget methods in C# are useful for executing background tasks without blocking the main application flow, but they come with their own set of challenges, particularly when working with scoped services. By leveraging techniques like IServiceScopeFactory, you can safely access scoped services in fire-and-forget tasks without risking lifecycle management issues. Whether you're sending emails, logging, or processing notifications, ensuring proper resource management is crucial to prevent errors like ObjectDisposedException. Always weigh the pros and cons of fire-and-forget and consider alternative approaches like background services or message queuing for more robust solutions in larger systems.

 

To explore more on this topic, you can check out the following resources on Microsoft Learn:

Updated Nov 23, 2024
Version 1.0
  • dateo's avatar
    dateo
    Copper Contributor

    Hi Syed,

    if I were to review this in a pull request, I would strongly encourage you to refactor this.
    Frankly, to call this code a "best practice" is dangerous advice to anyone seeking out knowledge in the realm of async programming in C#. Let me explain to you why I think that way:

    Sending off the async method `SendUserCreationMail` in a Task.Run and then not awaiting the corresponding task is as bad as having an `async void` signature in your code base and pretending your code would be async.

    The reason for this is that any exception happening inside of that method is lost in the CLR's nirvana and will never be caught by the outside try-catch because the task is not awaited. This is dangerous and can be the root cause for numerous bugs:

    • The consuming code will not know that there ever was an error. This code might work 99% of the time. The remaining 1% will be users complaining about emails that were not received and you were not even aware of this.
    • There is no chance of knowing that something was off from outside of SendUserCreationEmail, because nothing is logged. If you are lucky, it will pop up as an UnhandledTaskException, but the stacktrace information is still lost.
    • There is no chance of retrying to send the email, because of the above reasons.

    That being said, your ultimate goal is to do something (potentially long-running) without affecting the primary business process (add-user). There are many ways to do this properly:

    • Using an IHostedService and a simple mechanism to schedule work on a background thread (e.g., channels, queues, etc.)
    • Using a proper messaging solution (MassTransit, NServiceBus) on top of a message bus. Even in-memory would be a good fit, giving you the chance to configure retries for the actual sending out-of-the-box.

    TLDR, using an observed Task.Run is nothing more than a workaround and I would not encourage anyone to use this in a production environment.

    Cheers,

    Dennis

    • SyedShahriyar's avatar
      SyedShahriyar
      Brass Contributor

      Hello Dennis,

      Thank you for taking the time to provide such a detailed and thoughtful review. I really appreciate your feedback, as it brings up critical points that anyone delving into async programming should understand.

      You are absolutely correct in pointing out the dangers of using Task.Run in a fire-and-forget scenario without proper error handling. Exceptions going unobserved can indeed result in subtle and difficult-to-debug issues, especially in critical operations like email notifications.

      However, I’d like to clarify the intent of my blog post. The goal was to discuss the specific use case of fire-and-forget patterns for tasks that are non-critical, where introducing a full-blown background processing system or a messaging solution might be overkill. I emphasized the importance of using fire-and-forget correctly in such scenarios, not as a replacement for robust background task handling in critical workflows.

      Regards,

      Syed Shahriyar