๐ WAYPOINT LESSON
ValueTask: when and why
โญโญโญ advancedโณ 15 min read๐ Lesson 193 of 265
The allocation-free fast path, its two-contract rules, and the bugs it enables when misused.
ValueTask: when and why
Task<T> always allocates (and boxes T when it is a value type).
ValueTask<T> is a struct that holds EITHER a T or a Task<T> โ the
synchronous/hot path allocates nothing:
// Cache-first accessor: the ideal ValueTask citizen
ValueTask<int> GetAsync(int key) =>
_cache.TryGetValue(key, out var v)
? new ValueTask<int>(v) // no allocation
: new ValueTask<int>(FetchAsync(key)); // falls back to Task
The contract that keeps ValueTask safe (violating it is undefined behavior, not just a bug):
- Await exactly once. No double-await, no
.Resultafter await. - Never await concurrently.
- Do not store it โ await before the next await point, or call
.AsTask()if you must keep it. - Do not compose it directly (
WhenAll) โ convert with.AsTask()first.
When ValueTask is RIGHT: methods whose results are usually available
synchronously (caches, buffered reads, validation), or allocation-sensitive
hot paths measured to allocate. When it is WRONG: public library APIs
where callers will store/compose/await-twice โ the ergonomics trap eats the
performance win. Default to Task, escalate to ValueTask with evidence.