Skip to main content

Alloc-Free Hot Paths: How the Zaris .NET Client Uses ValueTask

· 6 min read
Clustron Team
Distributed Systems Engineering

The Zaris .NET client uses ValueTask on its hot-path operations

Most of the time, Task<T> is exactly the right return type for an async method, and you should not think twice about it. But there's a specific place where it quietly costs you: a hot path that completes synchronously, called millions of times.

Here's the mechanism. Task<T> is a reference type. Every call that returns one allocates a Task object on the heap — even when the operation completes synchronously. A cache hit served straight from a local buffer, an awaitable that's already done: none of that requires real asynchrony, but you still pay for a heap object to carry the result. One allocation is nothing. At the call rate of a high-throughput client — a tight loop doing millions of small Gets and Puts — that's a steady stream of small heap allocations, and that stream feeds the garbage collector. More gen-0 collections, more CPU spent on GC instead of your work, more jitter in your tail latencies.

The Zaris .NET client attacks this exactly where it matters and nowhere else. This post is about that decision: which methods changed, why, the rules you have to follow to use them correctly, and — deliberately — which methods we left alone.

What changed​

The client's hot-path single-key operations now return ValueTask / ValueTask<T> instead of Task / Task<T>. That's Get, Put, Delete, Expire, Persist, TTL, and the fluent chain builder.

ValueTask<T> is a struct. When the operation completes synchronously, it wraps the already-known result inline — zero heap allocation on that fast path. When the operation is genuinely asynchronous, it falls back to wrapping a real Task under the hood, so you lose nothing in the async case. You get the allocation-free path for free when the work is already done, and correct async behavior when it isn't.

Here's the shape of the change:

// Before — a Task<T> is allocated on every call, even a synchronous cache hit.
Task<ReadResult<Order>> GetAsync<T>(string key);
Task PutAsync<T>(string key, T value);

// After — a struct that allocates nothing when the result is already available.
ValueTask<ReadResult<Order>> GetAsync<T>(string key);
ValueTask PutAsync<T>(string key, T value);

For the overwhelmingly common case, your calling code doesn't change at all — you just await it:

// Reads exactly as before; allocates nothing when the value is served locally.
ReadResult<Order> result = await client.GetAsync<Order>("order:42");
if (result.Found)
Process(result.Value);

await client.ExpireAsync("session:abc", TimeSpan.FromMinutes(30));

The rules you have to follow​

ValueTask buys its performance by giving up some of Task's flexibility, and it enforces that trade with rules. Break them and you get undefined behavior, not a friendly exception. Three things to internalize:

  1. Await it at most once. A ValueTask may wrap a pooled or reused object. Awaiting it twice — or awaiting after you've already consumed the result — is not allowed and can return garbage or throw.
  2. Don't touch .Result before it's complete. Unlike Task, reading a ValueTask's result before it has finished isn't a blocking wait — it's simply invalid.
  3. Need it more than once? Convert to a Task first. If you have to store the result, await it in several places, or run several of them concurrently, call AsTask() and work with the Task from there.

That last one is the practical escape hatch. The moment you want to hold onto the pending operation, do this:

// Wrong: awaiting the same ValueTask twice.
ValueTask<ReadResult<Order>> pending = client.GetAsync<Order>("order:42");
var a = await pending;
var b = await pending; // undefined behavior — do not do this.

// Right: materialize a Task when you need to reuse or fan out.
Task<ReadResult<Order>> t = client.GetAsync<Order>("order:42").AsTask();
var first = await t;
var second = await t; // fine — Task is safe to await repeatedly.

// Right: running several concurrently — convert each, then combine.
Task<ReadResult<Order>> a = client.GetAsync<Order>("order:1").AsTask();
Task<ReadResult<Order>> b = client.GetAsync<Order>("order:2").AsTask();
await Task.WhenAll(a, b);

The simplest habit that keeps you safe: await the ValueTask immediately, right where you call it. Do that and you never trip any of the rules. Only when you deliberately want to store or reuse the operation do you reach for AsTask() — and then you're back in familiar Task territory.

A one-time, deliberate break​

Changing a method's return type from Task<T> to ValueTask<T> changes its signature, which makes this a binary-breaking API change. We did it on purpose, and we did it once, because it's precisely the kind of change you don't want to make twice. Rebuild against the new client and your source almost certainly compiles unchanged — the await sites are identical; it's the compiled assembly reference that has to be rebuilt.

Crucially, the wire protocol did not change. This is purely a client-side, in-process optimization about how results flow back through your own code. Servers are unaffected — there's no cluster redeploy required for this change, and a client built against the new signatures talks to the same nodes over the same protocol as before.

What we left on Task — and why​

The other half of the decision is the half we didn't change. Bulk operations, transactions, and scans deliberately stay on Task. These aren't hot-path, per-call primitives — they're larger, less frequent, coarser-grained operations. A single bulk write or a transaction already does substantial work, so one Task allocation amortized across it is genuinely negligible. And these are exactly the operations where Task's flexibility earns its keep: you often want to store them, await them in more than one place, or combine several with Task.WhenAll. Forcing ValueTask's single-await discipline onto them would trade real ergonomic value for an allocation you'd never notice.

That's the whole lesson, and it generalizes past this one client. Micro-allocations on a genuinely hot path are a real, measurable cost in a high-throughput system — worth engineering away. But ValueTask is a sharp tool with rules attached, and its payoff only shows up where the call rate is high and synchronous completion is common. Use it exactly there — the single-key hot path — and leave the flexible, allocation-tolerant operations on Task where flexibility is worth more than a saved allocation. The right tool, in the one place it's the right tool.