F# Async Workflows: Stop Wasting Server Threads on Cancelled Requests
F# async workflows with cooperative cancellation keep server threads available when clients disconnect or time out.
04 Oct 2025, 09:37 UTC

The Problem: Cancelled Requests That Keep Running
A client aborts an HTTP request after two seconds, but the server keeps running a database query and calling a downstream API for thirty more seconds. The thread stays occupied, the database connection remains open, and under load the thread pool starves. F# async workflows can prevent this waste, but only if you connect the workflow to the host's cancellation token instead of starting work and forgetting about it.
Thesis: Cooperative Cancellation Returns Threads to the Pool
F# Async<'T> supports cooperative cancellation through System.Threading.CancellationToken. When a workflow checks the token and the client has disconnected, the computation stops at the next let! boundary and the thread returns to the pool. The key is threading the token through every async operation in the request.
Passing Cancellation Through let!
Inside an async block, Async.CancellationToken returns the current token. Pass it to .NET APIs that accept CancellationToken, and to other async workflows. If the token is cancelled, the workflow throws OperationCanceledException at the next suspension point.
open System.Net.Http
open System.Threading
let fetchJson (client: HttpClient) (url: string) (ct: CancellationToken) =
async {
let! response = client.GetAsync(url, ct) |> Async.AwaitTask
let! content = response.Content.ReadAsStringAsync() |> Async.AwaitTask
return content
}
Async.AwaitTask converts a .NET Task into an F# async workflow. Passing ct into GetAsync lets the underlying HTTP call abort early. Because let! yields control, no thread is blocked while waiting for the network.
Worked Example: Request Handler With Timeout
Consider an endpoint that calls an external weather service. You want the operation to cancel if the client disconnects, and to time out after five seconds even if the client stays connected.
type WeatherResult = { City: string; Temperature: decimal }
let getWeatherWithTimeout (client: HttpClient) (city: string) (ct: CancellationToken) =
async {
use cts = new CancellationTokenSource(TimeSpan.FromSeconds(5.0))
use linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, cts.Token)
let linkedToken = linkedCts.Token
let! response =
client.GetAsync("https://api.example.com/weather/" + city, linkedToken)
|> Async.AwaitTask
let! json = response.Content.ReadAsStringAsync() |> Async.AwaitTask
let parsed = parseWeather json
return { City = city; Temperature = parsed.Temperature }
}
In an ASP.NET Core handler, pass HttpContext.RequestAborted as ct. Convert the resulting F# async workflow to a Task with Async.StartAsTask so the web pipeline can await it. If the client disconnects, the linked token source fires, the HTTP call aborts, and the thread is released.
Trade-offs and Limitations
- Debugging: Stack traces across
let!boundaries can be harder to read because the logical call stack does not match the thread stack. - Async.Start:
Async.Start workflowruns fire-and-forget and returns immediately. Exceptions inside the workflow are unobserved unless you handle them within the block. Avoid it for request-scoped work. - Async.RunSynchronously: Blocks the calling thread. Use it only in tests or console entry points, never inside a running server request.
- Cancellation is cooperative: If a downstream API ignores the token, the workflow cannot force it to stop. A timeout remains a necessary backstop.
How to Verify the Behavior
- Create a console project:
dotnet new console -lang F# - Write an async workflow that calls
Async.Sleep 10000inside a linked cancellation token source set to two seconds. - Start the workflow with
Async.StartAsTaskand cancel the source after two seconds. - Confirm the workflow throws
OperationCanceledExceptioninstead of running for ten seconds. - Repeat with
Async.Parallelacross three workflows and confirm that cancelling the shared token stops all of them.
Run this check in a non-production console app first. Verify that cancelled workflows release threads promptly and that exceptions surface at the workflow boundary rather than being silently dropped.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.