Dataflow blocks, linking with completion propagation, MaxDegreeOfParallelism, BoundedCapacity, SendAsync versus Post, batching and when it is the right tool.
TPL Dataflow (System.Threading.Tasks.Dataflow, a NuGet package) is a library of blocks you link into a pipeline: BufferBlock<T>, TransformBlock<TIn,TOut>, TransformManyBlock, BatchBlock<T>, ActionBlock<T>, BroadcastBlock<T> and join blocks. Each block has its own buffering, parallelism (MaxDegreeOfParallelism) and capacity (BoundedCapacity), and LinkTo with PropagateCompletion = true flows completion and errors through. I choose it for multi-stage pipelines with different parallelism per stage, batching or fan-out, where writing the same with channels would need a lot of plumbing. For a single stage, Parallel.ForEachAsync or a channel is simpler.
"TPL Dataflow is a library (
System.Threading.Tasks.Dataflow, a NuGet package) of blocks that you link into an in-process pipeline:BufferBlock,TransformBlock,TransformManyBlock,BatchBlock,ActionBlock,BroadcastBlockand join blocks. Each block has its own input buffer, degree of parallelism and bounded capacity.LinkTo(..., PropagateCompletion = true)makes completion and faults flow downstream, so you complete the head and await the tail'sCompletion. I choose it when the work is a genuine multi-stage pipeline whose stages need different resources: say 16 concurrent downloads, CPU-bound resizing at core count, and batched uploads with 2 writers, or when I need batching, fan-out or joins. Doing that with channels means writing the plumbing myself. For a single stage with limited concurrency I useParallel.ForEachAsync. For a simple queue between components,Channel<T>. For anything durable or cross-process, a message broker. Two Dataflow rules prevent most incidents: setBoundedCapacityeverywhere so backpressure works, and feed it withSendAsync, notPost."
| Block | Role | Typical use |
|---|---|---|
BufferBlock<T> |
Queue | Decouple producer and pipeline |
TransformBlock<TIn,TOut> |
1→1 map (sync or async) | Download, parse, resize |
TransformManyBlock<TIn,TOut> |
1→N map | Split files into lines, pages into items |
BatchBlock<T> |
Group N items into T[] |
Bulk inserts/uploads |
ActionBlock<T> |
Final consumer | Write, send, persist |
BroadcastBlock<T> |
Copy each item to all targets (latest-value semantics) | Fan-out to independent consumers |
JoinBlock<T1,T2> / BatchedJoinBlock |
Pair items from sources | Correlate two streams |
WriteOnceBlock<T> |
First value only | One-time signals |
| Option | Default | Effect |
|---|---|---|
MaxDegreeOfParallelism |
1 | Concurrent executions of the block's delegate |
BoundedCapacity |
Unbounded | Max items buffered and in processing → enables backpressure |
EnsureOrdered |
true |
Output order matches input order (costs buffering under parallelism) |
CancellationToken |
None | Cancels the block |
SingleProducerConstrained |
false |
Faster path if only one producer posts |
Greedy (grouping blocks) |
true |
Non-greedy avoids grabbing items it can't use yet (joins) |
| API | Behaviour |
|---|---|
await block.SendAsync(item, ct) |
Waits when the block is full: respects backpressure |
block.Post(item) |
Returns false immediately when full or declining: easy to lose items |
head.Complete() |
No more input |
LinkTo(target, new DataflowLinkOptions { PropagateCompletion = true }) |
Completion/faults flow downstream |
await tail.Completion |
Pipeline finished (or faulted) |
| Need | Tool |
|---|---|
| One loop over a collection with limited concurrency | Parallel.ForEachAsync |
| Queue between components / background worker | Channel<T> |
| Multi-stage pipeline, per-stage parallelism, batching, fan-out, joins | TPL Dataflow |
| CPU-bound data parallelism over in-memory data | Parallel.For / PLINQ |
| Durable, cross-process, retries, dead-lettering | Message broker (Service Bus, Kafka, RabbitMQ) |
Our [nightly image job] downloaded, resized and uploaded [200,000] product images strictly one after another and took [about 9 hours], often overrunning into business hours. A profile showed the CPU idle during downloads and the network idle during resizing. I rebuilt it as a Dataflow pipeline: a download TransformBlock with parallelism [16], a resize TransformBlock at Environment.ProcessorCount, a BatchBlock of [50], and an upload ActionBlock with parallelism [8]. Every block had BoundedCapacity [100], failures were routed to a dead-letter block, and items were fed with SendAsync. Runtime dropped to [about 50 minutes] with flat memory, and we exported per-block queue length and processed counts as metrics, with an alert on runtime.
Checks architecture choices for in-process pipelines.
Green flags
MaxDegreeOfParallelism and BoundedCapacity per stage.SendAsync versus Post.Parallel.ForEachAsync.Red flags
Sequential: [download][resize][upload][download][resize][upload]...
network CPU network
Pipeline: download x16 ──► resize xN(cores) ──► batch 50 ──► upload x8
(I/O bound) (CPU bound) (I/O bound, batched)
all stages run at the same time on different items
Throughput is limited by the slowest stage; you size that stage's parallelism and let bounded capacities stop faster stages from running ahead.
When a target block is at BoundedCapacity, it postpones offers from the source. The source keeps the item and offers it again when the target frees space. The source's own buffer then fills, so it postpones its upstream, and eventually SendAsync at the head waits. Without BoundedCapacity the whole chain degenerates into unbounded queues.
var deadLetter = new ActionBlock<(int Id, Exception Error)>(f => log.LogError(f.Error, "Item {Id} failed", f.Id));
var resize = new TransformManyBlock<Download, Resized>(async d =>
{
try { return [await ResizeAsync(d)]; }
catch (Exception ex)
{
await deadLetter.SendAsync((d.Id, ex));
return []; // swallow this item, keep the pipeline alive
}
}, new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, BoundedCapacity = 100 });
An unhandled exception faults the block: it stops accepting input, drops buffered items, and (with PropagateCompletion) faults downstream completions, which is right for fatal errors and wrong for one bad item.
source.LinkTo(evenTarget, x => x % 2 == 0);
// odd items match no link -> they stay in source forever -> pipeline never completes
source.LinkTo(DataflowBlock.NullTarget<int>()); // or a target for the rest
Always provide a link for items that match no predicate.
With MaxDegreeOfParallelism > 1 and EnsureOrdered = true, a slow item holds back faster ones behind it in the output buffer. If order doesn't matter, set EnsureOrdered = false for better throughput and lower memory.
InputCount / OutputCount per block → queue lengths (export periodically as metrics).Completion status per block for diagnosing where a pipeline stopped.| Version | Change |
|---|---|
| .NET Framework 4.5 | TPL Dataflow (out-of-band NuGet) |
| .NET Core | Same package, cross-platform |
| .NET 6 | Parallel.ForEachAsync covers many single-stage use cases |
| .NET 10 | Dataflow still a NuGet package; API stable |
BoundedCapacity values and feed 1,000,000 items; watch memory.SendAsync with Post and count false returns.PropagateCompletion from one link and observe await upload.Completion hanging.using System.Threading.Tasks.Dataflow; // NuGet: System.Threading.Tasks.Dataflow
var link = new DataflowLinkOptions { PropagateCompletion = true };
var download = new TransformBlock<int, byte[]>(async id =>
{
await Task.Delay(50); // simulate an HTTP download
return new byte[1024 + id];
}, new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 16, BoundedCapacity = 100 });
var resize = new TransformBlock<byte[], int>(bytes =>
{
return bytes.Length / 2; // simulate CPU work
}, new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount,
BoundedCapacity = 100
});
var batch = new BatchBlock<int>(50, new GroupingDataflowBlockOptions { BoundedCapacity = 200 });
int written = 0;
var upload = new ActionBlock<int[]>(async items =>
{
await Task.Delay(20); // simulate a bulk upload
Interlocked.Add(ref written, items.Length);
}, new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 2, BoundedCapacity = 4 });
download.LinkTo(resize, link);
resize.LinkTo(batch, link);
batch.LinkTo(upload, link);
for (int id = 0; id < 1_000; id++)
await download.SendAsync(id); // respects BoundedCapacity
download.Complete();
await upload.Completion;
Console.WriteLine($"Uploaded {written} images");
BoundedCapacity unboundedWhat happens: default options on every block.
Why it is wrong: fast stages buffer everything; memory grows; no backpressure.
Do instead: set BoundedCapacity on every block.
Post and ignoring its return valueWhat happens: block.Post(item); in a loop.
Why it is wrong: returns false when full: items silently dropped.
Do instead: await SendAsync, or check the result.
PropagateCompletionWhat happens: a.LinkTo(b);.
Why it is wrong: completion doesn't flow; await tail.Completion hangs.
Do instead: PropagateCompletion = true or complete each block manually.
What happens: an exception escapes a block delegate. Why it is wrong: the block faults, drops buffered items and stops the pipeline. Do instead: per-item try/catch with a dead-letter target; fault only on fatal errors.
What happens: items matching no predicate stay in the source.
Why it is wrong: the pipeline never completes.
Do instead: add a catch-all link (NullTarget or a dead-letter block).
What happens: one ActionBlock just to process a list with parallelism.
Why it is wrong: more code and concepts than needed.
Do instead: Parallel.ForEachAsync.
What happens: resize block with parallelism 64 on 8 cores. Why it is wrong: context switching and memory pressure without throughput gain. Do instead: about core count for CPU stages; higher for I/O stages.
EnsureOrdered when order doesn't matterWhat happens: default ordering with high parallelism.
Why it is wrong: head-of-line blocking and extra buffering.
Do instead: EnsureOrdered = false when order is irrelevant.
What happens: critical work lives only in blocks. Why it is wrong: in-memory; lost on restart. Do instead: checkpoint progress or use a broker for must-not-lose work.
What happens: the pipeline is a black box.
Why it is wrong: you can't see which stage is the bottleneck.
Do instead: export InputCount/OutputCount and processed/failed counts.
Dataflow is powerful but heavier than channels. I use it when the shape of the work is a real pipeline with different resource profiles per stage, and I always design capacity, parallelism and error routing per block.
| Stage type | Parallelism | Capacity |
|---|---|---|
| I/O download | High (8-32) | Moderate |
| CPU transform | About core count | Small |
| Database writes | Low, batched | Small |
| Dead-letter | 1 | Unbounded or logged |
System.Threading.Channels provides fast, async-friendly queues for passing data between producers and consumers. Channel.CreateBounded<T>(capacity) gives backpressure: when full, WriteAsync waits (or drops items, depending on BoundedChannelFullMode). Channel.CreateUnbounded<T>() never blocks writers but can grow without limit. Writers call Complete() when done; readers loop with await foreach (var x in reader.ReadAllAsync(ct)) and finish when the channel is completed and empty. Options such as SingleReader and SingleWriter enable faster internal implementations. Channels are the standard building block for in-process background queues and pipelines.
Parallel.ForEachAsync (.NET 6+) runs an async body for each item in a sequence (IEnumerable<T> or IAsyncEnumerable<T>) with a bounded number of concurrent operations, set by ParallelOptions.MaxDegreeOfParallelism (default: processor count). The body receives a CancellationToken that is cancelled if any iteration throws, so it stops early on failure. It is the simplest way to say "process these 10,000 items, at most 20 at a time". Compared with Task.WhenAll it limits concurrency; compared with SemaphoreSlim it needs less code. It does not return results, so write to a concurrent collection or a preallocated array.
How to tell it (STAR)
Our [image job] ran for nine hours because every step was sequential. I built a Dataflow pipeline with different parallelism per stage and bounded buffers, so downloads, resizing and uploads overlapped. Runtime dropped to [50 minutes] and memory stayed flat.
Last updated 31 Oct 2026.