Sync over async: the .Result that works fine until the day it doesn't
Somewhere in your codebase, right now, there is probably a line like this:
var customer = _customerService.GetCustomerAsync(id).Result;
It was written for an understandable reason. An async method needed calling from a synchronous
spot (a constructor, an old interface, a method whose signature somebody didn't want to change),
and .Result made the compiler happy in four keystrokes. It worked on the dev machine. It worked
in the demo. It has worked every single day since, because the trap in sync-over-async isn't a bug
you can reproduce with one request. It's a debt that only comes due under load.
What .Result actually does
await and .Result read like synonyms ("get me the value out of the task") and behave like
opposites. When you await a task that isn't finished, your method gets out of the way: it
returns to its caller, the thread goes back to useful work, and the rest of the method is scheduled
to resume when the result arrives. When you call .Result on a task that isn't finished, the
thread stands there. It blocks, holding every resource it represents, doing nothing, until the
task completes. (.Wait() and .GetAwaiter().GetResult() are the same standing-still with
different exception wrapping.)
await
thread returns to the pool during I/O
3 ms work · 40 ms I/O on no thread · 3 ms continuation
.Result
thread blocked for the entire wait
one thread held hostage for all 46 ms
One blocked thread is invisible. The failure modes come from what that blocked thread was for, and they differ by decade.
Failure mode one: the classic deadlock
In runtimes with a synchronization context (WPF and WinForms UI threads, classic ASP.NET), the
context is a queue that says "continuations run back on this thread." That makes .Result a
perfect self-inflicted deadlock:
public string GetGreeting()
{
// UI or classic ASP.NET context:
return GreetAsync().Result; // blocks the context thread, waiting for the task...
}
private async Task<string> GreetAsync()
{
var name = await LoadNameAsync(); // ...whose continuation is queued to run
return $"Hello, {name}"; // on the thread we just blocked.
}
The thread is waiting for the task; the task's continuation is waiting for the thread. Neither
yields. The app hangs forever, no exception, no log line, and this exact shape has burned .NET
developers for a decade. (This is also the real reason library authors write
ConfigureAwait(false): it lets the continuation run elsewhere, so a library awaiting internally
doesn't participate in the caller's deadlock. It is not a blessing to block.)
Failure mode two: the modern starvation cliff
ASP.NET Core removed the synchronization context, and a generation of developers concluded that
.Result is now safe. The deadlock is gone; the cost moved somewhere subtler. In ASP.NET Core,
requests are served by thread pool threads, and the pool is sized for threads that work, not
threads that stand around. Every request that blocks on .Result pins one pool thread for the
full duration of the I/O it's "waiting" on.
Now apply load. Fifty concurrent requests each block a thread for a 200 ms downstream call, and fifty pool threads are standing still. New requests arrive and there's no thread to run them, so they queue. The pool notices and grows, but it adds threads conservatively, a trickle against a flood (newer runtimes detect some blocking and inject faster, which softens the cliff without removing it, and every added thread is another megabyte of stack and another context-switching mouth to feed). Latency doesn't degrade gracefully; it's fine, fine, fine, and then the queue outruns the trickle and p99 goes vertical. The classic incident shape: "the app falls over at traffic spikes and recovers on its own," and nothing in any stack trace looks wrong, because standing still isn't an error.
The async version of the same endpoint releases the thread at every await. The same fifty
in-flight requests hold roughly zero threads while their I/O is out; threads only run code that's
actually running. That's the entire point of async in a server: it's not about making one request
faster, it's about not paying a thread per waiting request.
Unwinding it
The fix is unglamorous: make it async all the way up. Find the blocking call, make its
containing method async, await instead, and let the signature change ripple upward until it
reaches something that's already async (in ASP.NET Core, your endpoint or handler already is).
The ripple is annoying precisely once, which is cheaper than the alternative in every currency.
// Before: sync facade over async guts
public Invoice GetInvoice(Guid id)
{
var invoice = _repo.LoadAsync(id).Result;
var pdf = _pdfService.RenderAsync(invoice).Result;
invoice.AttachPdf(pdf);
return invoice;
}
// After: the signature tells the truth
public async Task<Invoice> GetInvoiceAsync(Guid id)
{
var invoice = await _repo.LoadAsync(id);
var pdf = await _pdfService.RenderAsync(invoice);
invoice.AttachPdf(pdf);
return invoice;
}
Two boundaries deserve honesty rather than tricks. Constructors can't be async; move the work to a
factory method or lazy initialization instead of blocking in new. And a genuinely synchronous
third-party interface you must implement is the one place a documented, quarantined block may be
the least-bad option; the sin is scattering casual .Result through code you do control because
propagating async felt like a chore.
The test suite will never tell you
Here's what makes this pattern durable in real codebases: it is completely invisible to functional testing. One request at a time, the blocking version returns the same bytes as the async version, a few hundred milliseconds slower at worst. Your unit tests run one thing at a time. Your integration tests run one thing at a time. The bug is concurrency, and almost nobody's feedback loop measures behavior under concurrent load until production does it for them.
That gap, between "passes the tests" and "survives production," is the one Katabench exists to close. The grading measures what production measures (wall-clock against a budget, allocations, the queries you fired) on the reasoning that you get good at exactly what your feedback loop grades, and a loop that only checks correctness trains you to write code that is merely correct. The how-it-works page shows what we measure and how. Sync-over-async is a concurrency lesson, and the habit that catches it (distrust code that's only ever been proven correct, one request at a time) is the habit every track is built to train.