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-09-01 · median of 4 quiet runs · typical spread ±3.3% · 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 — 10.9× faster than the baseline.
RESULTS
methodnet9.0net10.0ratioallocatedΔ net10.0
Task_completed baseline73.5 µs ±1.7 µs75.0 µs ±2.0 µs1.00×702.6 KB+2%
ValueTask_completed5.9 µs ±20 ns6.9 µs ±12 ns0.09×72 B+17%
RATIO VS BASELINE · net10.0
Task_completed1.00×
ValueTask_completed0.09×
THE LEDGER · SAME WORKLOAD ACROSS RELEASES (LOG)
Task_completedValueTask_completed
84.2 µs22.7 µs6.1 µs10.0.201 · 08-22 v210.0.201 · 09-01 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.4 µs ±1.2 µs458.8 µs ±1.1 µs1.00×130.8 KB-3%
Bounded_channel524.7 µs ±983 ns494.2 µs ±878 ns1.08×129.4 KB-6%
RATIO VS BASELINE · net10.0
Unbounded_channel1.00×
Bounded_channel1.08×
THE LEDGER · SAME WORKLOAD ACROSS RELEASES (LOG)
Unbounded_channelBounded_channel
559.2 µs476.1 µs405.3 µs10.0.201 · 08-22 v210.0.201 · 09-01 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