Table of Contents

CHT011: async method only forwards an awaited ValueTask

Cause

An async method returning ValueTask or ValueTask<T> does nothing but await a single inner ValueTask and return its result.

Rule Description

An async method compiles to a state machine, and its builder boxes that state machine onto the heap the first time the method suspends. When the method's entire body is "await one thing, return what it gave me", that machinery buys nothing the caller could not have had by receiving the inner ValueTask directly.

Eliding it removes two distinct costs:

  1. The box on suspension. Every call that actually suspends allocates a state machine box. A call that completes synchronously does not, so this cost only appears under contention — an uncontended benchmark reports zero either way and gives no hint the problem exists.
  2. Builder setup on every call. The async builder does work even when the method never suspends, so the synchronous fast path gets faster too.

The rule does not fire when the method does anything else with the awaited result, awaits more than once, awaits a Task rather than a ValueTask, or wraps the await in cleanup. That last case is reported as CHT012 instead, because it cannot be fixed mechanically.

How to Fix

Return the inner ValueTask directly:

// Before
public async ValueTask<Result> GetAsync(string key)
    => await _inner.GetAsync(key).ConfigureAwait(false);

// After
public ValueTask<Result> GetAsync(string key)
    => _inner.GetAsync(key);

ConfigureAwait(false) disappears along with the await, which is correct: configuring the await was a decision about this method's continuation, and there no longer is one. The caller makes its own choice when it awaits the returned ValueTask.

Option 2: Split validation into a non-async wrapper

Eliding async makes exceptions propagate synchronously rather than surfacing on the returned ValueTask. When the method validates arguments and callers depend on the old shape, keep validation outside the forwarding path:

// Before
public async ValueTask<Result> GetAsync(string key)
{
    if (key is null) throw new ArgumentNullException(nameof(key));
    return await _inner.GetAsync(key).ConfigureAwait(false);
}

// After - validation throws synchronously, forwarding stays allocation free
public ValueTask<Result> GetAsync(string key)
{
    if (key is null) throw new ArgumentNullException(nameof(key));
    return _inner.GetAsync(key);
}

Synchronous throwing is usually the better behaviour and matches how most of the framework validates arguments.

When to Suppress

Suppress when the wrapper exists for a reason the analyzer cannot see — for example when it deliberately keeps an inner type out of the public signature, or when a documented contract requires argument exceptions to surface on the returned ValueTask rather than at the call site.

#pragma warning disable CHT011 // Contract requires faulted ValueTask, not a synchronous throw

Example

Violating Code

public sealed class CachingStore
{
    private readonly IStore _inner;

    // CHT011: awaits one ValueTask and returns it, nothing more
    public async ValueTask<Result> GetAsync(string key)
    {
        return await _inner.GetAsync(key).ConfigureAwait(false);
    }
}

Fixed Code

public sealed class CachingStore
{
    private readonly IStore _inner;

    // OK - no state machine, no box, no builder setup
    public ValueTask<Result> GetAsync(string key)
    {
        return _inner.GetAsync(key);
    }
}

Performance Impact

Removing one such wrapper from AsyncKeyedLock<TKey> in this library cut the uncontended acquisition from 71.8 ns to 40.3 ns — a 44% improvement on a path that was already allocation free, because the builder costs setup work even when the method never suspends.

The allocation saving is separate and applies only to calls that suspend, at roughly one box per suspension.

See Also