Synchronous-Over-Async (sync-over-async)
Sync-over-async blocks a thread waiting on an async operation, paying both models' costs and risking thread-pool starvation or deadlock on single-context runtimes. Go async all the way to the entry point and use native async APIs instead of blocking on tasks.
Sync-over-async is calling an asynchronous API and then immediately blocking the current thread until it completes — task.Result, task.Wait(), future.get(), runBlocking, await asyncio.run(...) inside sync code, or block_on. You pay the overhead of the async machinery (state machines, continuations, context captures) and the cost of holding a thread idle, getting the worst of both worlds. The reverse, async-over-sync (wrapping a blocking call in a task just to look async), is a related smell that merely hides a blocking call.
Why It Happens
Async spreads virally — to call an async method you must be async too — and somewhere a developer wants to stop the cascade, often at a boundary with a synchronous interface (a constructor, an old framework, a library API). Blocking on the task seems like a harmless bridge. It usually works in tests, so it ships, and the hidden hazards only appear under load or specific synchronization contexts.
Why It Hurts
The blocked thread does nothing but wait, defeating the scalability async was meant to provide; under load the pool fills with blocked threads waiting on tasks that need a free thread to complete — a self-inflicted thread-pool starvation. In environments with a single-threaded synchronization context (classic ASP.NET, UI frameworks), blocking on a task that needs to resume on that same context produces a hard deadlock: the context is busy waiting for the task, and the task cannot resume. The result is intermittent hangs that are extremely hard to diagnose.
Warning Signs
.Result,.Wait(),GetAwaiter().GetResult(),future.get()with no timeout on a request or UI thread.runBlocking/block_ondeep inside otherwise-async code.- Thread pool growing while throughput stalls; periodic deadlocks under load.
- Async methods wrapped with
Task.Runsolely to call them from sync code.
Better Alternatives
- Async all the way: propagate
async/await(or suspend functions, coroutines) up to the top-level entry point. - Use native async APIs end to end rather than blocking on async ones.
ConfigureAwait(false)(.NET) on library code to avoid capturing a context that can deadlock.- Make the entry point async (async
Main, async controller actions, async event handlers) so there is no sync boundary to bridge. - If you must block, do it at a true top-level boundary with a timeout, never inside library code.
How to Refactor Out of It
Trace the async chain to the point where it is forced synchronous and push the boundary outward: make the caller async, repeating up to the framework entry point, which modern frameworks support. Remove .Result/.Wait()/get() from hot paths. In .NET libraries, add ConfigureAwait(false) so resumption does not require a captured context, eliminating the deadlock class. Where a synchronous library is unavoidable, prefer a real synchronous API for it instead of blocking on an async wrapper. Load-test to confirm the thread pool no longer balloons and hangs disappear.