Using F# Async Workflows for Non‑Blocking I/O
Learn how to compose non‑blocking I/O in F# using async workflows, start them correctly, handle cancellation, and avoid common pitfalls.
04 Sept 2026, 03:28 UTC

Problem: Blocking calls waste threads
When a program performs I/O such as reading a file or making a network request, a synchronous call blocks the calling thread until the operation completes. In high‑concurrency scenarios this can exhaust the thread pool and increase latency.
Takeaway: Compose non‑blocking work with async { }
The F# async computation expression lets you describe asynchronous steps without creating threads explicitly. The expression returns an Async<'T> value that represents a cold workflow; you start it with Async.StartAsync (or Async.Start) and the runtime schedules continuations on the thread pool.
How async workflows work
Inside an async { } block you can use let! and do! to bind to other asynchronous computations. Each let! yields control back to the scheduler while awaiting the inner task, and resumes when the task completes. The workflow is compiled to a state machine similar to C# async methods.
Worked example: reading a file asynchronously
#r "System.IO.FileSystem.FileStream";;
open System.IO
open System.Threading
let readFileAsync (path:string) =
async {
// Convert the .NET Task to an Async<'T>
let! content = Async.AwaitTask (File.ReadAllTextAsync(path))
return content
}
// Usage: start the workflow and print the result when complete
let workflow = readFileAsync "C:\\data\\sample.txt"
Async.StartAsync workflow |> Async.AwaitTask |> ignore
The readFileAsync function returns an Async. When started, it yields the thread while the OS performs the file read, then resumes with the file contents.
Starting and composing workflows
Workflows can be started fire‑and‑forget with Async.Start or awaited with Async.StartAsync which returns a Task. You can also compose them using the pipeline operators:
let combined = async {
let! a = readFileAsync "file1.txt"
let! b = readFileAsync "file2.txt"
return a + b
}
Async.Start combined
Cancellation and error handling
Pass a CancellationToken to Async.StartAsync (or Async.StartWithContinuations) to propagate cancellation:
let cts = new CancellationTokenSource()
Async.StartAsync workflow cts.Token |> ignore
// Later:
cts.Cancel()
Exceptions raised inside the async block are captured and become part of the returned Async<'T>. You can handle them with try...with inside the workflow or expose them via Async.Catch.
Limits and common mistakes
- Forgotten start: Defining an async block but never calling
Async.Startleaves the work cold; nothing executes. - Blocking on Async: Using
Async.RunSynchronouslyin UI or ASP.NET contexts can dead‑lock if the synchronization context expects the continuation to run on the same thread. - GC pressure: Very large numbers of short‑lived async allocations increase garbage‑collection overhead; consider pooling or using
ValueTask-based primitives when available. - Mixing sync and async: Calling a blocking API inside an async block (e.g.,
File.ReadAllText) stalls the thread and defeats the purpose. - Context loss: Implicit flow of ASP.NET
HttpContextor WPFDispatcheris not preserved across await points; pass required objects explicitly.
Verification and practical checks
- In F# Interactive, enable timing:
#time "true". Run the workflow and observe that the reported elapsed time is much smaller than the blocking equivalent. - Inspect the generated IL: compile the script to a DLL and run
ildasmon the assembly; you will see a state machine class with methods likeMoveNext. - Check the type:
typeof>.Nameshould returnAsync`1, confirming you have a cold workflow before starting. - For cancellation, start a workflow with a token source, cancel after a short delay, and verify that the continuation does not run (e.g., by not printing a success message).
When to avoid async workflows
If the operation is CPU‑bound and does not involve I/O, a pure asynchronous workflow offers little benefit; consider Task.Run or parallel libraries instead. Also, in low‑latency tight loops where the overhead of allocating an async object outweighs the I/O wait, a synchronous approach may be preferable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.