Cooperative Cancellation and Per‑Call Deadlines in F# Async Workflows
Add per‑call deadlines and cooperative cancellation to F# async workflows with <code>Async.StartChild</code> and <code>CancellationToken</code>. A concrete example shows how to guard a slow dependency and propagate cancellation in an ASP.NET Core service.
27 May 2026, 06:22 UTC

The Problem
When an F# service calls several slow dependencies one after another, a single sluggish call can make the whole request feel stuck. In ASP.NET Core a request‑level CancellationToken is only useful after the dependency returns – the thread is already blocked. The goal is to keep the operation asynchronous end‑to‑end, expose a per‑call deadline, and propagate cancellation so that the child operation can stop early.
Designing with Async Workflows
F# async workflows are a lightweight, composable alternative to Task. They let you write let! bindings that automatically await the next step without blocking a thread. For a request‑level token you typically receive it from the ASP.NET Core pipeline and thread it through every call:
let rec fetchData (ct: CancellationToken) = async {
// ...
}
Each I/O routine should accept the token and periodically check ct.IsCancellationRequested or pass it to the underlying .NET API that supports cancellation.
Guarding a Single Call
F#’s Async.StartChild lets you run a child workflow with a built‑in timeout. The timeout limits how long you wait for the child to finish; if it expires you can return a fallback value. Importantly, the timeout does not automatically cancel the child – you must pass the same CancellationToken into the child so it can observe cancellation.
let guardWithTimeout (timeoutMs: int) (ct: CancellationToken) (op: Async<int option>) = async {
let! child = Async.StartChild(op, timeout = timeoutMs)
try
return! child
with
| :? TimeoutException as ex ->
// Timeout reached – return fallback
return None
}
Example usage with a fake slow operation:
let slowOp (ct: CancellationToken) = async {
do! Async.Sleep 1000 // Simulate I/O
return Some 42
}
let result =
guardWithTimeout 500 CancellationToken.None slowOp
|> Async.RunSynchronously
// result will be None because the 500 ms timeout expired before 1000 ms sleep
To make the child respect the request’s cancellation token you can link a CancellationTokenSource:
let cts = CancellationTokenSource.CreateLinkedTokenSource(requestCt)
let result =
guardWithTimeout 500 cts.Token slowOp
|> Async.RunSynchronously
Now, if the HTTP request is aborted, requestCt is cancelled, the linked cts is cancelled, and slowOp can observe ct.IsCancellationRequested and exit early.
Putting It All Together
In a real service you often need to call multiple dependencies. Wrap each call with the guard and compose them sequentially:
let fetchAll (requestCt: CancellationToken) = async {
let! a = guardWithTimeout 500 requestCt (dependencyA requestCt)
let! b = guardWithTimeout 500 requestCt (dependencyB requestCt)
let! c = guardWithTimeout 500 requestCt (dependencyC requestCt)
return a, b, c
}
Because each call is awaited with let!, the thread is never blocked and cancellation propagates through the chain.
Trade‑offs & Limitations
- Async vs Task:
Asyncis great for composition andStartChild, but interop with libraries that expectTaskmay requireAsync.StartAsTaskorTask.FromResult. Newer F#task { }expressions can be used when you need a trueTaskat the boundary. - Timeout is a Wait Limit:
StartChildonly limits how long you wait; the child may continue running. Ensure the child cooperatively checks the token to avoid background work. - Blocking Calls: Avoid
Async.RunSynchronouslyorTask.Waitinside request handlers. Keep everything asynchronous to free the thread pool. - Concurrency:
Async.Parallelstarts all workflows immediately. Use it only when you can tolerate unbounded fan‑out; otherwise sequence withlet!.
Actionable Checklist
- Pass the request
CancellationTokeninto every async routine. - Wrap each I/O call with
guardWithTimeout(or a similar helper) to enforce per‑call deadlines. - Link the request token to a
CancellationTokenSourcewhen starting the workflow:CancellationTokenSource.CreateLinkedTokenSource(requestCt). - At the boundary to .NET APIs that return
Task, useAsync.StartAsTaskand propagate the same token. - Write a unit test that simulates a slow call and a cancelling request to verify both timeout and cancellation paths.
- Monitor the service for orphaned background work; if you see tasks continuing after a request, add token checks to the child workflows.
By following this pattern you keep your service fully asynchronous, respect per‑call deadlines, and ensure that a cancelled request does not leave lingering background work.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.