F# async vs task: safe interop in .NET 6
How to convert between F# Async<'T> and System.Threading.Tasks.Task at .NET boundaries without deadlocks or type errors.
22 Sept 2026, 20:11 UTC

The type wall between F# async and .NET tasks
You write a small asynchronous function in F#:
let work () =
async {
return 42
}
Then you try to pass `work ()` to a .NET method that expects `System.Threading.Tasks.Task`. The compiler rejects it. The reason is that F# `async { }` is a computation expression: F# syntax for building a value in a custom workflow. It produces `Async<'T>`, an F# representation of asynchronous work. It is not the same type as `Task<'T>`, even though both describe work that completes later.
Convert at the edges, not inside the logic
Keep your core logic in `async { }` where you can use F# primitives such as `Async.Parallel` and `do!`. When you need to hand work to a .NET library, convert explicitly at the boundary.
open System.Threading.Tasks
let coreWork () =
async {
do! Async.Sleep(100)
return 42
}
// Expose F# work to callers that expect a Task
let coreWorkAsTask () =
Async.StartAsTask(coreWork())
`Async.StartAsTask` produces a `Task`. The conversion is explicit, so the caller knows they are receiving a task, and the F# side stays in its native model.
When the dependency is a task-based API and you need its result inside an `async { }` block, use `Async.AwaitTask`:
let consumeTask (source: Task) =
async {
let! value = Async.AwaitTask(source)
return value + 1
}
Use task { } when the boundary is the starting point
F# 6 introduced the `task { }` computation expression. It builds `Task<'T>` directly, which can simplify interop when your entry point is already task-based, such as an ASP.NET Core endpoint or a method called from C#.
let endpoint () =
task {
return 42
}
This requires F# 6 or later and a project targeting .NET 6 or newer. It avoids one conversion, but `task { }` does not compose directly with F# async primitives. If you need `Async.Parallel` or `Async.CancellationToken`, stay in `async { }` and convert once at the edge.
Cancellation and exceptions do not cross automatically
A surrounding .NET `CancellationToken` is not automatically inherited by an `async { }` block. Pass it explicitly:
let delayed (ct: System.Threading.CancellationToken) =
async {
do! Async.Sleep(1000, ct)
return 42
}
Exceptions use F# `try/with`. A fault inside an `async { }` block surfaces when the workflow is executed or awaited, not when the value is constructed.
What to avoid
- Do not `await` an `Async<'T>` value directly from C#. Convert it with `Async.StartAsTask` first.
- Do not call `Async.RunSynchronously` in UI code or on a thread with a synchronization context. It blocks the thread and can deadlock.
- Do not mix `async { }` and `task { }` without explicit conversion; the compiler will report a type mismatch.
Verify the types yourself
- Create a console project: `dotnet new console -lang F# -o AsyncDemo`
- Open `Program.fs` and add the snippets above.
- Build with `dotnet build`. Hover over `coreWork ()` in your editor; the inferred type should be `Async`.
- Hover over `coreWorkAsTask ()`; it should be `System.Threading.Tasks.Task`.
- Replace `async { }` with `task { }` in `coreWork` and confirm it compiles only if your SDK has F# 6 or later.
If the editor shows `Async` where you expected `Task`, you have found the boundary that needs `Async.StartAsTask`.
Closing
F# async and .NET tasks are different types that solve similar problems. Write F#-native code in `async { }`, expose it as `Task<'T>` with `Async.StartAsTask` when crossing into .NET APIs, and use `task { }` only when you are already at a task-based boundary. Never block with `Async.RunSynchronously` in a context-bound environment.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.