📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is async-over-sync, why is wrapping synchronous code in Task.Run often a mistake, and when is it acceptable?

Fake async wrappers, why they do not scale on servers, when offloading is right (UI responsiveness), and how to integrate blocking legacy SDKs safely.

Senior Architecture Advanced api-design async-over-sync scalability task-run

🎯 Key points

  • Async-over-sync wraps blocking code in Task.Run to look asynchronous
  • On servers it still blocks a pool thread and adds overhead
  • Library APIs should not hide Task.Run; expose sync as sync
  • Acceptable in UI apps to keep the UI thread responsive
  • Real scalability needs true async I/O
  • Isolate unavoidable blocking SDKs on dedicated bounded threads or a worker
  • An Async suffix promises non-blocking behaviour

⏱ 30-second answer

Async-over-sync means exposing or calling synchronous, blocking code through an async-looking API, typically Task.Run(() => DoBlockingWork()). On a server it does not add scalability: the blocking still happens, just on another pool thread, plus the cost of a hop. As a library API (public Task<T> GetAsync() => Task.Run(Get)) it misleads callers into thinking the operation is truly asynchronous. It is acceptable at the edge of a UI app to keep the UI thread responsive, or to move CPU-bound work off a request thread deliberately. For blocking legacy SDKs on servers, isolate them on dedicated threads with bounded concurrency until a real async API exists.

🗣 Full answer (senior)

How I would answer this in an interview

"Async-over-sync means making blocking code look asynchronous, usually Task.Run(() => DoBlockingWork()), either inside an ...Async method you publish or at a call site. On a server it doesn't buy any scalability. The blocking still happens, just on another pool thread, and you pay for the queue hop and allocations. Worse, it hides the blocking: under load you get ThreadPool starvation even though your code contains no .Result. In a library it's misleading: an Async suffix promises non-blocking I/O, and hiding Task.Run takes away the caller's choice of where work runs. So the guidance is: expose synchronous methods as synchronous, and asynchronous ones only when the implementation is really async. Where Task.Run is right: in UI apps, to keep the UI thread responsive during CPU work, and occasionally on servers to deliberately move a heavy CPU step into a bounded background queue. For blocking legacy SDKs on servers, I isolate them on a few dedicated threads with bounded concurrency until a real async API exists."


1. What it looks like

// Library: fake async
public Task<Report> GetReportAsync() => Task.Run(() => GetReport());   // GetReport blocks on I/O

// Server call site: "making it async"
app.MapGet("/report", async () => await Task.Run(() => legacy.GetReport()));

2. Thread accounting (one request, 1 s of blocking I/O)

Code Threads held while waiting Extra cost
Synchronous call in the request 1 pool thread —
await Task.Run(blockingCall) 1 (different) pool thread Queue hop, task allocation, context switch
True async I/O (await client.GetAsync(...)) 0 —

Task.Run doesn't create capacity; it moves the blocking. Only true async I/O releases the thread.

3. Where it's acceptable

Situation Acceptable? Why
UI app: CPU-heavy work after a click Yes The goal is freeing the UI thread
UI app: blocking legacy I/O Yes, as a bridge Keeps the UI responsive; still costs a pool thread
Server: wrap blocking I/O No (temporary at most) Still blocks a pool thread; hides starvation
Server: heavy CPU step Sometimes, bounded Doesn't add CPU; can protect other requests if moved to a limited background queue
Library public API No Let callers decide; don't lie with Async
Tests / console tools Fine Scalability doesn't matter

4. Handling a blocking legacy SDK on a server

Option Notes
1. Real async alternative New SDK version, or call the vendor's REST API with HttpClient
2. Dedicated threads + bounded queue A few named threads own the blocking; callers await a TaskCompletionSource; pool untouched
3. Separate worker process/service Move the SDK behind a queue (job id + status endpoint)
4. Document as tech debt With metrics on queue depth and latency

5. Naming and API design

Method Implementation Name
Truly async I/O await on async APIs GetReportAsync
Blocking Synchronous GetReport (and document it)
CPU-bound Synchronous Calculate (caller offloads if needed)

The .NET guidance: "don't expose asynchronous wrappers for synchronous methods" and "don't expose synchronous wrappers for asynchronous methods".

Production example

Our [reporting API] rendered PDFs with a synchronous library that fetched fonts over the network on first use, and the team had wrapped it in await Task.Run(() => pdf.Render(...)) believing that made it async. At [month-end] load, latency for every endpoint rose to [about 10 s] with CPU at [35%] and a growing pool queue. Dumps showed dozens of pool threads blocked inside the PDF library's network calls, reached through the Task.Run wrapper. I moved rendering to a background job queue processed by [4] dedicated threads, changed the endpoint to return a job id with a status endpoint (202 Accepted), and cached fonts locally so rendering no longer touched the network. The rest of the API returned to normal latency, and we added a guideline banning Task.Run wrappers around I/O in server code.

🔍 What the interviewer is checking

Checks understanding of what async actually buys you.

Green flags

  • Explains that Task.Run around blocking I/O does not free threads.
  • Knows library guidance against fake async.
  • Distinguishes UI from server use.
  • Has a plan for blocking legacy SDKs.

Red flags

  • "Wrap it in Task.Run and it becomes async."
  • Uses Task.Run everywhere in controllers.

📚 Learn in depth

1. Why the ThreadPool doesn't care where you block

request thread R:  await Task.Run(Blocking)   -> R returns to the pool (good)
pool thread W:     Blocking()                 -> W is blocked for 1 s (bad)
net effect:        one blocked pool thread per in-flight request, same as before, plus overhead

At 500 concurrent requests you still need ~500 blocked threads; the pool injects them slowly, which is exactly starvation.

2. CPU-bound offload on servers

ASP.NET Core requests already run on pool threads, so await Task.Run(Cpu) doesn't increase CPU available; it just hops. When it can help:

  • protecting latency-sensitive endpoints by moving rare, very heavy CPU jobs into a bounded background queue (e.g. Channel<T> with N consumers);
  • isolating work with a different priority/SLA.

When it hurts: wrapping every CPU step in Task.Run (extra hops, no gain), or unbounded offloading (CPU contention for everyone).

3. A channel-based gateway for blocking SDKs

public sealed class BlockingGateway : IAsyncDisposable
{
    private readonly Channel<Func<Task>> _queue = Channel.CreateBounded<Func<Task>>(200);
    private readonly Thread[] _threads;

    public BlockingGateway(int threads)
    {
        _threads = Enumerable.Range(0, threads).Select(i => new Thread(Worker) { IsBackground = true, Name = $"sdk-{i}" }).ToArray();
        foreach (var t in _threads) t.Start();
    }

    private void Worker()
    {
        foreach (var job in _queue.Reader.ReadAllAsync().ToBlockingEnumerable())   // blocks this dedicated thread only
            job().GetAwaiter().GetResult();
    }

    public async Task<T> RunAsync<T>(Func<T> call, CancellationToken ct)
    {
        var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
        await _queue.Writer.WriteAsync(() =>
        {
            try { tcs.TrySetResult(call()); } catch (Exception ex) { tcs.TrySetException(ex); }
            return Task.CompletedTask;
        }, ct);                                    // async backpressure for callers
        return await tcs.Task.WaitAsync(ct);
    }

    public ValueTask DisposeAsync()
    {
        _queue.Writer.TryComplete();
        foreach (var t in _threads) t.Join(TimeSpan.FromSeconds(5));
        return ValueTask.CompletedTask;
    }
}

Only the gateway's threads ever block; callers await; concurrency is bounded by thread count and queue capacity.

4. Exceptions with Task.Run

Exceptions inside Task.Run are captured in the task. If you await it, they surface normally; if you fire and forget it, they're unobserved and silently lost. In UI apps, always await the offloaded work inside a try/catch in the handler.

5. Spotting async-over-sync in code review

Smell Example
Task.Run returning an ...Async method's result in a library => Task.Run(() => Foo())
Task.Run in controllers around DB/HTTP calls await Task.Run(() => db.Users.ToList())
Task.FromResult(BlockingCall()) Looks async, runs synchronously and blocks the caller
Interfaces with only async methods implemented synchronously public Task SaveAsync() { File.WriteAllText(...); return Task.CompletedTask; } (blocks the caller)

6. Version notes

Version Change
.NET 4.5 Task.Run; guidance on async wrappers published by the .NET team
.NET Core 3.0 Channels in-box
.NET 6 Parallel.ForEachAsync; managed thread pool
.NET 7 ToBlockingEnumerable for IAsyncEnumerable<T>
.NET 10 Same guidance

7. Try it yourself

  1. Endpoint A: Thread.Sleep(500); endpoint B: await Task.Run(() => Thread.Sleep(500)); endpoint C: await Task.Delay(500). Load-test each at 200 concurrency and compare queue length and threads.
  2. Run the code example and confirm only the named gateway threads block (dotnet-stack).
  3. In a WPF/WinForms app, run CPU work with and without await Task.Run and compare UI responsiveness.
  4. Fire-and-forget a Task.Run that throws and observe that nothing is reported.
  5. Review a codebase for Task.Run( in server projects and classify each use.

💻 Code

using System.Collections.Concurrent;

// Isolate a blocking legacy SDK on dedicated threads with bounded concurrency.
using var gateway = new BlockingSdkGateway(threadCount: 4);
var results = await Task.WhenAll(Enumerable.Range(1, 10)
    .Select(i => gateway.RunAsync(() => LegacySdk.Render($"report-{i}"))));
Console.WriteLine(string.Join(", ", results));

public static class LegacySdk
{
    public static string Render(string name)
    {
        Thread.Sleep(200); // blocking network call inside a vendor library
        return name;
    }
}

public sealed class BlockingSdkGateway : IDisposable
{
    private readonly BlockingCollection<Action> _work = new(boundedCapacity: 100);
    private readonly Thread[] _threads;

    public BlockingSdkGateway(int threadCount)
    {
        _threads = Enumerable.Range(0, threadCount).Select(i => new Thread(() =>
        {
            foreach (var item in _work.GetConsumingEnumerable()) item();
        }) { IsBackground = true, Name = $"legacy-sdk-{i}" }).ToArray();
        foreach (var t in _threads) t.Start();
    }

    // Task-returning facade that never blocks a ThreadPool thread on the SDK.
    public Task<T> RunAsync<T>(Func<T> call)
    {
        var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
        _work.Add(() =>
        {
            try { tcs.SetResult(call()); }
            catch (Exception ex) { tcs.SetException(ex); }
        });
        return tcs.Task;
    }

    public void Dispose()
    {
        _work.CompleteAdding();
        foreach (var t in _threads) t.Join();
        _work.Dispose();
    }
}

Note that _work.Add blocks the caller when the queue is full; in a real server use a bounded Channel<T> and WriteAsync.

⚠ Common mistakes

1. Publishing Task.Run wrappers as ...Async methods

What happens: public Task<T> GetAsync() => Task.Run(Get); in a library. Why it is wrong: callers believe it's non-blocking and use it in servers. Do instead: expose the synchronous method; implement real async I/O where possible.

2. Wrapping blocking I/O on servers "to make it async"

What happens: await Task.Run(() => db.Orders.ToList()). Why it is wrong: a pool thread still blocks; starvation still happens, now hidden. Do instead: async APIs (ToListAsync), or isolate unavoidable blocking on dedicated threads.

3. Task.Run around every CPU step in controllers

What happens: each computation is offloaded. Why it is wrong: extra hops and allocations, no extra CPU, higher latency. Do instead: run CPU work inline; move only rare heavy jobs to a bounded queue.

4. Not offloading CPU work in UI apps

What happens: heavy computation runs in a click handler. Why it is wrong: the UI freezes. Do instead: await Task.Run(...) in UI code.

5. Fire-and-forget Task.Run

What happens: _ = Task.Run(Work);. Why it is wrong: exceptions are unobserved; no backpressure; lost on shutdown. Do instead: await it, or use a background queue (see fire-and-forget question).

6. Async interfaces implemented synchronously without saying so

What happens: SaveAsync() does File.WriteAllText and returns Task.CompletedTask. Why it is wrong: blocks the caller while promising async. Do instead: use async APIs inside, or document the blocking.

7. Unbounded offloading

What happens: every request starts a Task.Run for heavy work. Why it is wrong: CPU contention and pool exhaustion under load. Do instead: bound concurrency (queue + fixed consumers).

8. Treating Task.Run as a cure for starvation

What happens: "we added Task.Run so requests return faster". Why it is wrong: the same number of threads are blocked; the queue still grows. Do instead: remove blocking or isolate it on dedicated threads.

9. Using LongRunning for async lambdas as an "isolation" fix

What happens: Task.Factory.StartNew(async () => ..., LongRunning). Why it is wrong: the dedicated thread is released at the first await. Do instead: dedicated threads for synchronous blocking only.

10. No metrics on the isolation layer

What happens: a gateway queue fills silently. Why it is wrong: latency grows with no visibility. Do instead: export queue depth and wait time.

🏛 Senior insight: trade-offs

The question I ask is: what resource does this protect? Task.Run protects a specific thread (the UI thread), not system capacity. On servers the scarce resource is pool threads, so blocking work must be isolated or removed, not moved around.

Context Right approach
UI, CPU-heavy await Task.Run(...)
Server, I/O True async APIs
Server, unavoidable blocking SDK Dedicated bounded threads or worker process
Server, heavy CPU job Background queue, separate service
Library No hidden Task.Run

🔗 Follow-ups the interviewer may ask

  1. When: Candidate contrasts with blocking on async Why: Checks the opposite anti-pattern
    How to answer

    Sync-over-async is synchronously blocking on asynchronous work: .Result, .Wait(), GetAwaiter().GetResult() or Task.WaitAll. It holds a thread for the whole operation, causes ThreadPool starvation on servers and deadlocks under a SynchronizationContext. It sneaks in through constructors, DI factory lambdas, property getters, Dispose, lock blocks, synchronous interfaces (IComparer, old filters), static initialisers and "quick fixes" in legacy code. Remove it by making the call chain async, using async factory methods or lazy async initialisation, IAsyncDisposable, hosted-service warm-up, and async-capable interfaces. Where a sync boundary is unavoidable (for example a legacy interface), keep it at the edge and document it.

  2. When: Candidate says hidden blocking causes starvation Why: Checks starvation diagnosis
    How to answer

    ThreadPool starvation happens when pool threads are blocked faster than the pool can add new ones, so queued work (requests, continuations, timers) waits. Symptoms: latency rises sharply while CPU stays low, the pool queue length grows, thread count climbs slowly, health checks time out and Kestrel logs heartbeat warnings mentioning possible starvation. I confirm it with dotnet-counters (queue length and thread count), then take dotnet-stack snapshots or a dump to see what the threads are blocked on, usually .Result, .Wait(), synchronous I/O or locks. The fix is to make that code asynchronous; raising SetMinThreads only buys time.

  3. When: Candidate suggests dedicated threads Why: Checks when to create threads
    How to answer

    A System.Threading.Thread is a managed wrapper over one dedicated operating-system thread with its own stack and scheduling. Creating one is expensive (a stack reservation, a kernel object, scheduler work), so normal application code uses the ThreadPool through Task.Run, async/await or framework hosts. I only create a Thread directly for long-lived, dedicated work: a message pump, a blocking legacy loop, a thread that needs a specific apartment state, name, priority or stack size, or work that must never compete with pool threads.

🛠 Real-world problems: how you handle them

Fake async PDF rendering starving an API
Situation
A [reporting API] wrapped a synchronous PDF library (which fetched fonts over the network) in `Task.Run` and awaited it.
Signals
Under [month-end] load, latency for all endpoints rose to [10 s]; CPU [35%]; pool queue length growing.
Investigation
Dumps showed dozens of pool threads blocked inside the PDF library's network calls, called from the `Task.Run` wrapper.
Resolution
Moved rendering to a background job queue processed by [4] dedicated threads, returning a job id and a status endpoint; cached fonts locally.
Prevention
Added a guideline banning `Task.Run` wrappers for I/O in server code.

How to tell it (STAR)

At [month end] our whole API slowed down. The PDF rendering looked async because it was wrapped in `Task.Run`, but the library blocked on network calls, so it still used pool threads. I moved rendering to a separate queue with a few dedicated threads and returned a job id. The rest of the API went back to normal latency.

More in Concurrency Production Problems

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me