CodeGym /Courses /C# SELF /Exceptions in "fire and forget" tasks

Exceptions in "fire and forget" tasks

C# SELF
Level 61 , Lesson 0
Available

1. What is "fire and forget"?

In programming the term fire and forget means starting a task without waiting for its completion. In the C# and .NET world this is most often done with Task instances that are started but never awaited (await), no reference is kept and they're effectively forgotten.

// The button starts a background task, but it's never awaited.
button.Click += (s, e) =>
{
    Task.Run(() => DolgayaOperatsiya());
};

Sounds tempting: "let it run in the background while I do my thing". But with this approach, if an exception happens inside the task, nobody will learn about it in time — it will quietly get lost.

2. How exception handling works in Task

Classic: await and error handling

The standard way to work with async tasks is via await. If an error occurs in the task, it will be rethrown at the await point:

try
{
    await SomeOperationAsync(); // if there is an Exception inside, it will surface to catch
}
catch(Exception ex)
{
    Console.WriteLine("Whoops! The task failed: " + ex.Message);
}

So when you await a task you won't miss the exception.

But fire-and-forget tasks are not awaited!

public void StartWithoutWaiting()
{
    // The task runs on its own. Nobody awaits it...
    Task.Run(() => {
        // Somewhere inside trouble happens:
        throw new InvalidOperationException("Oy, everything's lost!");
    });
    // The method finished, the task runs silently in the background.
}

If an exception occurs inside such a task, it will not be thrown on the main thread. The app keeps running as if nothing happened.

Important

In .NET a task with an unhandled exception moves to the Faulted state. But if you don't observe it (await, .Result, .Wait(), etc.), nobody reads the exception and it doesn't show up in the caller code.

What actually happens "under the hood"?

For tasks nobody awaits, the only remaining chance to be noticed is the TaskScheduler.UnobservedTaskException event. It fires when the garbage collector (GC) finds a task with an unobserved exception. But this doesn't happen immediately and not where you expect — you can't rely on it.

3. Demonstration: Fire-and-forget error

// Example: starting a fire-and-forget task right from Main
using System;
using System.Threading.Tasks;

class Program
{
    static void Main(string[] args)
    {
        FireAndForgetExample();

        Console.WriteLine("Main thread keeps working...");
        // Give the task time to finish
        Task.Delay(2000).Wait();
    }

    static void FireAndForgetExample()
    {
        Task.Run(() =>
        {
            Console.WriteLine("Fire-and-forget task started!");
            Task.Delay(500).Wait();
            throw new InvalidOperationException("Error inside fire-and-forget task!");
        });
    }
}

If you run this code, nothing special will happen. The error occurs, but the program doesn't know about it. Sometimes you might see a warning in the IDE Output Window, but the user gets no information.

Why is this dangerous in real projects?

  • Hard-to-reproduce bugs ("sometimes it fails — no idea why").
  • Silent loss of data or logic (for example, an email wasn't sent).
  • In production — no signals about issues unless logging is configured.

4. Correct ways to handle errors in fire-and-forget

Logging and handling errors inside the task itself

The minimal safe level — catch exceptions right inside the fire-and-forget task:

Task.Run(() =>
{
    try
    {
        // Your long/risky code
        throw new InvalidOperationException("Something went wrong!");
    }
    catch (Exception ex)
    {
        // Log the error or notify the user
        Console.WriteLine("Fire-and-forget: caught exception: " + ex.Message);
        // You can write to a log file, use an alerting system, etc.
    }
});

Async void methods (and why you shouldn't do this)

async void DangerousFireAndForget()
{
    // Something risky
    throw new Exception("Boom!");
}

async void methods are essentially fire-and-forget: you can't wait for them, they don't return a Task. Exceptions from them go to the app-global handler (for example, AppDomain.UnhandledException) and can often crash the process. Use async void only for event handlers — and even then with care.

Using helper methods to handle errors

It's convenient to extract safe fire-and-forget startup into a wrapper:

// Generic method for safely starting a fire-and-forget task
public static void RunSafeFireAndForget(Func<Task> taskFactory)
{
    Task.Run(async () =>
    {
        try
        {
            await taskFactory();
        }
        catch (Exception ex)
        {
            // Log the exception
            Console.WriteLine("Fire-and-forget (safe): " + ex);
            // You can add sending to monitoring here!
        }
    });
}

// Usage:
RunSafeFireAndForget(async () =>
{
    await Task.Delay(1000);
    throw new InvalidOperationException("Inside fire-and-forget!");
});

Real-life example: sending email

// Send button:
private void buttonSend_Click(object sender, EventArgs e)
{
    Task.Run(() => SendEmail());
}

// Send method:
private void SendEmail()
{
    try
    {
        // There could be real sending here
        throw new Exception("SMTP-server is unavailable!");
    }
    catch (Exception ex)
    {
        // Logging
        File.AppendAllText("errors.log", $"Send error: {ex.Message}\n");
    }
}

5. What about UnobservedTaskException?

As a last resort, .NET provides the TaskScheduler.UnobservedTaskException event. It is called if a task faulted, nobody awaited it, and the task object was collected by GC. Relying on this is a bad idea — it's a "last-chance" mechanism.

TaskScheduler.UnobservedTaskException += (sender, e) =>
{
    Console.WriteLine("Global UnobservedTaskException: " + e.Exception);
    e.SetObserved(); // Don't forget to call this, otherwise the app may terminate!
};

More: TaskScheduler.UnobservedTaskException.

6. Useful nuances

Schematic comparison of approaches

Approach Exceptions handled? Where to catch errors Risk of "losing" an error
await
Yes In the caller Low
Fire-and-forget without try/catch No Nowhere Very high
Fire-and-forget with try/catch Yes Inside the task itself Low (if you log)
async void method No (goes to global) Global handler High

How to design fire-and-forget correctly

  • If the result or state of the task is critical — don't do fire-and-forget. Use await or keep the Task to await it later.
  • Fire-and-forget is only justified for truly non-critical background work (e.g., telemetry sending).
  • Always wrap fire-and-forget in your own method and catch/log exceptions.
  • For complex background scenarios use queues and workers: Hangfire, Quartz.NET.

Practical use and interviews

In interviews you may be asked: "What happens if an exception occurs in a fire-and-forget task?" or "Why can't we use async void everywhere?" The right answer: you're responsible for the fate of errors in background tasks — either catch, log and analyze them, or get phantom bugs.

Mapping "fire-and-forget" vs await

Scenario Reliability of error handling Applicability
Regular await Excellent Where a result is needed or success/fail matters
Fire-and-forget Poor (if not handled manually) Only for truly background and non-critical tasks
Fire-and-forget with try/catch Good (if you log) Background tasks where result isn't needed but failures should be known

In the next lecture we'll discuss error handling in parallel tasks that return multiple results. Meanwhile remember: if you "fire" something, make sure it actually "hits" the target!

7. Typical mistakes when working with fire-and-forget tasks

Mistake #1: Ignoring exceptions in fire-and-forget.
Beginners hope exceptions will "bubble up" somewhere. Without try-catch and logging they get lost, causing undetectable bugs.

Mistake #2: Using async void outside of event handlers.
Such methods throw exceptions to the global handler (e.g., AppDomain.UnhandledException), which can crash the app.

Mistake #3: Over-catching exceptions.
Catching all exceptions inside the task can hide problems that would be better handled by the caller, making debugging harder.

Mistake #4: Neglecting logging.
Without logging errors in fire-and-forget tasks you can't know about failures, especially in production.

2
Task
C# SELF, level 61, lesson 0
Locked
Global UnobservedTaskException Handler
Global UnobservedTaskException Handler
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION