async programming
exception handling
F#
concurrency
error propagation

Why does Async.Start seemingly propagate uncatchable exceptions?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In F#, Async.Start fires off an async computation on the thread pool and does not return a handle to observe its result. If the computation throws an exception, there is no calling code to catch it — the exception propagates to the thread pool's unhandled exception handler, which by default crashes the process. This makes exceptions from Async.Start appear "uncatchable." The fix is to handle exceptions inside the async block, use Async.StartWithContinuations for explicit error callbacks, or use Async.StartAsTask to get a Task whose exception can be observed.

The Problem

fsharp
1open System
2
3let riskyWork = async {
4    printfn "Starting work..."
5    failwith "Something went wrong"  // Unhandled exception
6}
7
8// This try/catch does NOT catch the exception
9try
10    Async.Start riskyWork
11with
12| ex -> printfn "Caught: %s" ex.Message // The exception crashes the process — try/catch is useless here printfn "This line runs, then the process crashes" ``` `Async.Start` returns `unit` immediately. The async computation runs later on a thread pool thread. The `try/with` only wraps the call to `Async.Start` (which does not throw), not the computation itself. ## Fix 1: Handle Exceptions Inside the Async Block The simplest approach — wrap the async body in `try/with`: ```fsharp let safeWork = async { try printfn "Starting work..." failwith "Something went wrong" with | ex -> printfn "Caught inside async: %s" ex.Message } Async.Start safeWork // Output: Caught inside async: Something went wrong // No crash ``` This keeps the exception within the async computation and prevents it from reaching the thread pool. ## Fix 2: Async.StartWithContinuations Provide explicit success, error, and cancellation handlers: ```fsharp let riskyWork = async { printfn "Starting work..." failwith "Something went wrong" return 42 } Async.StartWithContinuations( riskyWork, (fun result -> printfn "Success: %d" result), (fun ex -> printfn "Error: %s" ex.Message), (fun cancel -> printfn "Cancelled: %s" cancel.Message) ) // Output: Error: Something went wrong ``` This gives full control over all three outcomes without modifying the original async computation. ## Fix 3: Async.Catch `Async.Catch` wraps the result in a `Choice` — either the value or the exception: ```fsharp let riskyWork = async { failwith "Something went wrong" return 42 } let handledWork = async { let! result = Async.Catch riskyWork match result with | Choice1Of2 value -> printfn "Success: %d" value | Choice2Of2 ex -> printfn "Caught: %s" ex.Message } Async.Start handledWork // Output: Caught: Something went wrong ``` This is useful when composing async workflows — you can catch errors from sub-computations without crashing. ## Fix 4: Async.StartAsTask Convert to a `Task<T>` and observe the exception through the Task: ```fsharp open System.Threading.Tasks let riskyWork = async { failwith "Something went wrong" return 42 } let task = Async.StartAsTask riskyWork // Option A: Await the task (in another async or Task-based context) task.ContinueWith(fun (t: Task<int>) -> if t.IsFaulted then printfn "Task error: %s" t.Exception.InnerException.Message else printfn "Task result: %d" t.Result ) | > ignore // Option B: In an async block let observe = async { try let! result = Async.AwaitTask task printfn "Result: %d" result with |
13| --- | --- | --- | --- | --- |
14| `Async.Start` | `unit` | No | Must handle inside or use continuations |
15| `Async.RunSynchronously` | `'T` | Yes | `try/with` around the call works |
16| `Async.StartAsTask` | `Task<'T>` | No | Via `Task.Exception` or `await` |
17| `Async.StartWithContinuations` | `unit` | No | Explicit error callback |
18| `Async.StartImmediate` | `unit` | Partial | Must handle inside |
19
20## Cancellation Handling
21
22```fsharp
23open System.Threading
24
25let cts = new CancellationTokenSource()
26
27let cancelableWork = async {
28    for i in 1..100 do
29        printfn "Step %d" i
30        do! Async.Sleep 100
31}
32
33Async.StartWithContinuations(
34    cancelableWork,
35    (fun () -> printfn "Done"),
36    (fun ex -> printfn "Error: %s" ex.Message),
37    (fun _ -> printfn "Cancelled"),
38    cts.Token
39)
40
41// Cancel after 500ms
42Thread.Sleep 500
43cts.Cancel()
44// Output: Step 1, Step 2, Step 3, Step 4, Cancelled

Common Pitfalls

  • Wrapping Async.Start in try/with expecting to catch the async exception: Async.Start returns immediately without throwing. The try/with only catches exceptions from the Start call itself (which never throws). Handle exceptions inside the async block or use StartWithContinuations.
  • Using Async.Start for work whose errors must be observed: If you need to know whether the computation succeeded or failed, Async.Start is the wrong choice. Use Async.StartAsTask to get a Task you can inspect, or Async.StartWithContinuations for callbacks.
  • Ignoring cancellation in long-running async computations: Async.Start accepts an optional CancellationToken, but the computation must cooperate by checking for cancellation (using Async.Sleep, Async.CancellationToken, etc.). CPU-bound loops without cancellation points cannot be cancelled.
  • Calling Async.RunSynchronously on the UI thread: This blocks the UI thread, causing the application to freeze. Use Async.StartImmediate or Async.Start with SynchronizationContext for UI-bound work.
  • Not setting SetObserved() on UnobservedTaskException: In .NET 4.0, unobserved Task exceptions crashed the process by default. In .NET 4.5+, they are swallowed silently. Relying on either behavior is fragile — always handle exceptions explicitly.

Summary

  • Async.Start fires and forgets — exceptions crash the process because no code observes them
  • Handle exceptions inside the async block with try/with for simple cases
  • Use Async.StartWithContinuations for explicit success/error/cancel callbacks
  • Use Async.Catch to convert exceptions into Choice values for composition
  • Use Async.StartAsTask when interoperating with Task-based code or when you need to observe the result

Course illustration
Course illustration

All Rights Reserved.