BENCHMARKS · ASYNC MACHINERY

Task vs ValueTask overhead and channel throughput. Suite and raw reports: github.com/gdhami-net/dotnet-benchmarks.

Intel Core Ultra 9 285HX · Windows 11 · BenchmarkDotNet 0.15.8 · SDK 10.0.201 · 2026-08-22 · single run — not yet median-of-N · bars/tables: mean time, lower is better

AwaitOverhead

Await 10,000 already-completed operations: Task vs ValueTask — the overhead argument, measured.

TAKEAWAY ValueTask pays off when a method usually completes synchronously (caches, buffered readers). If the result is usually awaited across threads or stored, plain Task stays simpler and safe.
In this run: ValueTask_completed is fastest — 15.3× faster than the baseline.
RESULTS
methodnet9.0net10.0ratioallocatedΔ net10.0
Task_completed baseline88.9 µs ±11.5 µs90.9 µs ±13.8 µs1.00×702.6 KB+2%
ValueTask_completed7.9 µs ±31 ns5.9 µs ±41 ns0.07×72 B-24%
RATIO VS BASELINE · net10.0
Task_completed
1.00×
ValueTask_completed
0.07×
THE LEDGER · SAME WORKLOAD ACROSS RELEASES (LOG)
Task_completedValueTask_completed
102.0 µs23.2 µs5.3 µs10.0.201 · 08-22 v210.0.201 · 08-22 v3
THE CODE BEING MEASURED
Task_completed — what this measures

Awaiting Task.FromResult in a loop — one allocation per call when not cached.

[Benchmark (Baseline)]
public async Task<int> Task_completed()
{
    var sum = 0;
    for (var i = 0; i < 10_000; i++) sum += await GetTask(i);
    return sum;
}
ValueTask_completed — what this measures

Awaiting a completed ValueTask — no allocation on the synchronous path.

[Benchmark]
public async Task<int> ValueTask_completed()
{
    var sum = 0;
    for (var i = 0; i < 10_000; i++) sum += await GetValueTask(i);
    return sum;
}

ChannelThroughput

Push 10,000 items through a Channel and read them back: unbounded vs bounded.

TAKEAWAY Bounded channels cost a little more per item - that's the price of backpressure, and on a real queue you almost always want it. Unbounded means your memory is the backpressure.
In this run: Unbounded_channel is fastest (it is the baseline).
RESULTS
methodnet9.0net10.0ratioallocatedΔ net10.0
Unbounded_channel baseline472.2 µs ±1.5 µs463.3 µs ±6.3 µs1.00×130.8 KB-2%
Bounded_channel529.4 µs ±1.1 µs501.7 µs ±4.7 µs1.08×129.4 KB-5%
RATIO VS BASELINE · net10.0
Unbounded_channel
1.00×
Bounded_channel
1.08×
THE LEDGER · SAME WORKLOAD ACROSS RELEASES (LOG)
Unbounded_channelBounded_channel
562.9 µs477.7 µs405.3 µs10.0.201 · 08-22 v210.0.201 · 08-22 v3
THE CODE BEING MEASURED
Unbounded_channel — what this measures

Channel.CreateUnbounded — no backpressure, no capacity checks.

[Benchmark (Baseline)]
public async Task<int> Unbounded_channel()
{
    var ch = Channel.CreateUnbounded<int>();
    for (var i = 0; i < 10_000; i++) await ch.Writer.WriteAsync(i);
    ch.Writer.Complete();
    var sum = 0;
    await foreach (var v in ch.Reader.ReadAllAsync()) sum += v;
    return sum;
}
Bounded_channel — what this measures

Channel.CreateBounded with room for everything — the capacity bookkeeping alone.

[Benchmark]
public async Task<int> Bounded_channel()
{
    var ch = Channel.CreateBounded<int>(10_000);
    for (var i = 0; i < 10_000; i++) await ch.Writer.WriteAsync(i);
    ch.Writer.Complete();
    var sum = 0;
    await foreach (var v in ch.Reader.ReadAllAsync()) sum += v;
    return sum;
}

speed vs allocation

Fast is one axis. What it costs the GC is the other.

Task_completedValueTask_completedUnbounded_channelBounded_channel
mean time → (log)allocated → (log)Task_completedValueTask_completedUnbounded_channelBounded_channel