Fetching Many Things at Once in F#: Async Workflows Without the Threading Headaches
F# async workflows make concurrent I/O-bound work simple with async { } and Async.Parallel — if you avoid the blocking-call trap that starves the thread pool.
23 Aug 2026, 00:03 UTC

You have a list of two hundred URLs to fetch, or a queue of files to process, and the naive loop runs them one at a time. Each call spends most of its life waiting on the network, so the whole job takes the sum of every latency. The useful takeaway: in F#, async { } workflows plus Async.Parallel let you overlap that waiting with a few lines of code, without manually managing threads, callbacks, or task continuations.
This matters because I/O-bound concurrency is the common case in services and tools, and it is also where hand-rolled threading code goes wrong: forgotten exception handling, unbounded parallelism, and blocking calls that quietly exhaust the thread pool.
What an async workflow actually is
An async { } block is a computation expression. The code inside it does not run when you define it; it builds a value of type Async<'T>, a description of work that you start explicitly. Inside the block, let! (pronounced "bind") starts another async operation and suspends the workflow until it completes, releasing the underlying thread back to the pool while waiting. That release is the whole point: a blocked thread is wasted memory, but a suspended workflow is just a small heap object.
Nothing runs until you hand the workflow to an executor. The two you will use most:
Async.RunSynchronously— starts the workflow and blocks the calling thread until it finishes. Fine at the top of a console app or a test; a mistake inside a server request handler.Async.Start— fire-and-forget on the thread pool, for work whose result you do not need to await.
A worked example: parallel fetching with a timeout
Here is a complete console-program sketch (F# 6 or later, .NET 6+; run with dotnet run in a project created via dotnet new console -lang F#). It fetches a list of URLs concurrently, applies a per-request timeout, and keeps one slow endpoint from stalling the batch:
open System.Net.Http
let http = new HttpClient()
let fetch (url: string) = async {
let! body = http.GetStringAsync(url) |> Async.AwaitTask
return (url, body.Length)
}
let fetchAll urls =
urls
|> List.map (fun u -> Async.Timeout(5000, fetch u))
|> Async.Parallel
[<EntryPoint>]
let main _ =
let urls = [ "https://example.com"; "https://fsharp.org" ]
try
let results = Async.RunSynchronously (fetchAll urls)
results |> Array.iter (fun (u, len) -> printfn "%s -> %d bytes" u len)
0
with :? System.TimeoutException ->
printfn "at least one request exceeded 5 seconds"
1Three details are worth understanding:
Async.AwaitTaskconverts a .NETTask(which most library APIs return) into anAsync. You will write this constantly at library boundaries.Async.Paralleltakes a sequence of workflows and returns one workflow producing an array of results. If any child fails or times out, the combined workflow raises that exception — there is no built-in "return partial results," so wrap children individually if you need that.Async.Timeout(milliseconds, workflow)raisesTimeoutExceptionif the inner workflow has not finished in time. Note it does not cancel the underlying HTTP request by itself; it stops waiting for it.
To verify non-blocking behavior, add #time "on" in F# Interactive (dotnet fsi) and run the workflow against several URLs: total elapsed time should be close to the slowest single request, not the sum. A practical unit test is to call Async.RunSynchronously inside a test with its own timeout and assert the expected array length.
The trap: blocking inside async
The most common way to break this model is to call a synchronous blocking API inside a workflow — for example http.Send(...), stream.Read(...), or Task.Result. The workflow still looks asynchronous, but the thread-pool thread executing it sits blocked for the full I/O duration. Under load, enough of these exhaust the pool's threads, and the runtime's slow thread-injection ramp-up turns a latency problem into a throughput collapse.
The rule of thumb: inside async { }, only bind with let! to genuinely asynchronous operations. If a library only offers a synchronous API, isolate it with Async.SwitchToThreadPool awareness or accept the cost deliberately — do not hide it in the middle of a hot workflow.
Cancellation and the honest trade-offs
Async workflows carry an implicit cancellation token, checked automatically at each let!. Pass one explicitly with Async.RunSynchronously(workflow, cancellationToken = token) when you need cooperative shutdown. Cancellation is cooperative: it stops the workflow between binds, not mid-blocking-call.
Be aware of the limitations before committing:
Async.Parallelis unbounded. Two hundred URLs means two hundred concurrent requests, which can exhaust sockets or anger a rate-limited API. For large batches, chunk the input or use aSemaphoreSlimaround the work.- The F#
Asynctype predatesTaskand interoperates imperfectly with C#-centric libraries. Thetask { }builder (built into modern F#) is often the better choice for new code that lives among Task-based APIs;async { }remains excellent for cancellation-token propagation and existing F# codebases. - Debugging suspended workflows is harder than debugging a straight-line loop; keep individual workflows small and named.
Where to start
Pick one I/O-bound loop in your current project — a batch fetch, a file-processing queue — and convert it using the pattern above: one small workflow per item, Async.Parallel over the list, a timeout per item, and Async.RunSynchronously only at the program's entry point. Then time it against the sequential version. If the elapsed time does not drop toward the slowest single operation, look for a blocking call hiding inside the workflow — that is almost always the culprit.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.