Below is the runtime-level review I would use myself before a senior or leadership interview.
PART 1 — CORE CONCEPTS RECAP
Task vs Thread vs ThreadPool
Thread
A Thread is an actual OS-managed execution thread. It has its own stack, scheduling cost, context-switch cost, and lifetime. It is heavy compared with normal async constructs.
Mental model: a thread is a real worker.
ThreadPool
The .NET ThreadPool is a runtime-managed pool of reusable worker threads. Instead of creating a brand new OS thread for every small unit of work, .NET queues work items to the pool and lets existing workers execute them.
Mental model: a shared warehouse of reusable workers.
Task
A Task is not a thread. A Task is an object representing the eventual completion of some operation. It may be backed by:
- ThreadPool work
- true async I/O completion
- manual completion via
TaskCompletionSource - already-computed results
- other continuations
Mental model: a Task is a handle to a future result, not the worker itself.
So:
Thread= execution resourceThreadPool= pool of reusable execution resourcesTask= promise/future representing work or completion
synchronous vs asynchronous vs parallel
Synchronous
The caller waits until the operation finishes before moving on.
var text = File.ReadAllText(path);One flow, one step at a time.
Asynchronous
The operation may complete later, and the caller can yield while waiting.
var text = await File.ReadAllTextAsync(path);Important: async is mainly about not blocking while waiting.
Parallel
Multiple operations execute at the same time or are scheduled to make progress concurrently, typically for throughput or CPU utilization.
Parallel.ForEach(items, item => Process(item));Important: parallel is mainly about doing more work at once, not waiting efficiently.
IO-bound vs CPU-bound
IO-bound
The bottleneck is external waiting:
- disk
- network
- database
- timers
- OS/device operations
Best tool: async I/O, because the thread should not sit idle while waiting.
CPU-bound
The bottleneck is computation:
- parsing
- image processing
- encryption
- data transformation
- numerical work
Best tool: use CPU threads intentionally, often ThreadPool or dedicated workers.
Rule of thumb:
- waiting on outside world -> async
- burning CPU -> parallelism or background workers
PART 2 — WHAT TASK REALLY IS
Task as a promise + completion object
At its core, Task is a runtime object that represents:
- completion status
- result or exception
- cancellation state
- registered continuations
- optional scheduling/execution metadata
It is both:
- a promise to the consumer
- sometimes also the runtime representation of scheduled work
That second part causes confusion. A Task can represent actively scheduled computation, but it can also represent something that no thread is currently running, like an I/O request waiting for the OS.
Task as “state machine + promise”
When people say “Task is a state machine + promise,” the precise version is:
- the async method becomes a compiler-generated state machine
- the Task is the externally visible completion object produced by the method builder
So the async method logic lives in the state machine, while the Task is the thing consumers await.
Task lifecycle
The simplified lifecycle is:
- Created
- Scheduled
- Running
- WaitingForChildrenToComplete sometimes
- RanToCompletion
- Faulted
- Canceled
For most async/await code, you rarely see the full classic Task lifecycle explicitly because the compiler and builder hide it.
Important distinction:
new Task(...)
This creates a task in Created state. It does not start automatically.
Task.Run(...)
This creates and schedules immediately, usually to the ThreadPool.
async method returning Task
This often creates a task-like completion object through AsyncTaskMethodBuilder; it is not necessarily “scheduled” in the same way as Task.Run.
TaskCompletionSource
TaskCompletionSource<T> lets you create a Task<T> whose completion you control manually.
var tcs = new TaskCompletionSource<int>();
Task<int> task = tcs.Task;
tcs.SetResult(42);This is important because it shows clearly that:
- a
Taskdoes not require a dedicated executing thread - a
Taskcan simply be completed later by some callback, event, or I/O completion
It is often used to wrap:
- callback-based APIs
- event-based APIs
- custom async coordination
Think of TaskCompletionSource as “manual promise producer.”
how continuations are stored and executed
When you await a task, the awaiter may register a continuation with that task.
Conceptually:
- if task is already complete -> continue inline
- otherwise -> attach continuation callback
- when task completes -> invoke or schedule stored continuations
Internally, Task maintains continuation data in optimized forms, not always a simple list. The runtime uses several internal continuation representations to reduce allocations and handle special cases:
- single action
- list/collection of continuations
- specialized continuation objects for scheduler/context behavior
When the task completes, it transitions atomically to a final state and then publishes/runs continuations.
Key idea: continuations are the real bridge between “pause now” and “resume later.”
How Task differs from Thread
A Thread:
- is an actual execution lane
- has a stack
- runs instructions directly
- is expensive
A Task:
- is a completion abstraction
- may or may not use a thread
- has no dedicated stack of its own
- can represent pure waiting
This is the most important mental correction many developers still need.
await SomeNetworkCallAsync() usually does not mean “a background thread is doing my work.” It often means:
- request sent
- no managed thread is busy
- when OS signals completion, continuation gets scheduled
PART 3 — HOW ASYNC/AWAIT IS COMPILED
The compiler transforms async methods into state machines
Take this:
public async Task<int> FooAsync()
{
await BarAsync();
return 42;
}The compiler roughly rewrites it into:
- a generated struct implementing
IAsyncStateMachine - a
MoveNext()method - an
AsyncTaskMethodBuilder<int> - fields storing locals, awaiters, and state
Conceptually:
struct FooAsyncStateMachine : IAsyncStateMachine
{
public int _state;
public AsyncTaskMethodBuilder<int> _builder;
private TaskAwaiter _awaiter;
public void MoveNext()
{
int result;
try
{
if (_state == 0)
{
goto ResumeAfterAwait;
}
var awaitable = BarAsync();
var awaiter = awaitable.GetAwaiter();
if (!awaiter.IsCompleted)
{
_state = 0;
_awaiter = awaiter;
_builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
}
_awaiter = awaiter;
ResumeAfterAwait:
_awaiter.GetResult();
result = 42;
}
catch (Exception ex)
{
_builder.SetException(ex);
return;
}
_builder.SetResult(result);
}
public void SetStateMachine(IAsyncStateMachine stateMachine) { }
}This is simplified, but the shape is correct.
generated MoveNext()
MoveNext() is the heart of the async method.
It:
- checks current state
- executes until next await
- if awaited operation is incomplete, stores state and exits
- if awaited operation completed, continues immediately
- on resume, jumps back into the right state
- on success, sets result
- on failure, sets exception
This is why async methods are resumable without preserving the original stack the way synchronous methods do.
The original call stack unwinds at suspension points. The continuation later re-enters through MoveNext().
role of AsyncTaskMethodBuilder
AsyncTaskMethodBuilder or AsyncTaskMethodBuilder<T> is the compiler/runtime helper that:
- creates the task exposed to the caller
- coordinates completion
- stores result or exception
- wires continuations
- interacts with execution/synchronization context machinery
You can think of it as the bridge between:
- the generated state machine
- the public
Task
It is a builder for the externally visible async result.
Typical flow:
- compiler creates builder
- state machine starts
- builder manages task completion
- caller receives builder.Task
how await is implemented under the hood
await is pattern-based. The compiler looks for:
GetAwaiter()IsCompletedOnCompleted(...)orUnsafeOnCompleted(...)GetResult()
So conceptually:
var awaiter = someAwaitable.GetAwaiter();
if (!awaiter.IsCompleted)
{
// suspend method
awaiter.OnCompleted(continuation);
return;
}
// already completed
awaiter.GetResult();That is the fundamental await shape.
Task, ValueTask, and custom awaitables all plug into this pattern.
PART 4 — HOW AWAIT ACTUALLY WORKS AT RUNTIME
Let’s walk through one await.
await SomeOperationAsync();Step 1: call the async method
SomeOperationAsync() returns a task-like object.
Step 2: get the awaiter
Compiler-generated code does:
var awaiter = task.GetAwaiter();Step 3: check completion
It checks:
if (awaiter.IsCompleted)Two branches happen.
Branch A: synchronous completion
If the task is already complete:
- no suspension is needed
- no continuation registration is needed
- execution continues immediately in the current call
Then:
awaiter.GetResult();This:
- returns the result, or
- throws the stored exception, or
- throws cancellation-related exception
This is why async methods can complete synchronously and why not every await causes a true async hop.
Branch B: incomplete task
If not completed:
1. store current state
The state machine saves:
- current state number
- awaiter
- any needed locals
2. register continuation
The builder/awaiter arranges for the state machine’s continuation to run later, typically by re-invoking MoveNext().
3. return to caller
The async method exits early. It does not block the current thread.
At this point:
- the caller has a
Task - the method is logically paused
- the thread is free to do other work
4. awaited operation completes later
Completion may happen because:
- ThreadPool work finished
- OS I/O completion arrived
- timer fired
TaskCompletionSourcewas completed- another continuation ran
5. continuation gets scheduled
The awaiter schedules the continuation according to captured context/scheduler rules.
6. MoveNext() runs again
Now the state machine resumes from the stored state.
7. GetResult()
It pulls the result or rethrows stored failure.
That is the full suspension/resume loop.
synchronous completion vs asynchronous continuation
This distinction matters a lot in real systems.
synchronous completion
The awaited task was already finished, so the method continues inline. No queue hop required.
asynchronous continuation
The task finished later, so continuation is posted/scheduled and resumes later.
This is why async behavior can vary:
- sometimes code continues on the same thread immediately
- sometimes it pauses and resumes later on another thread or context
A lot of “weird async behavior” is just developers not noticing which branch happened.
PART 5 — SYNCHRONIZATIONCONTEXT & CONTEXT CAPTURE
what SynchronizationContext is
SynchronizationContext is an abstraction that says: “if someone needs to resume work, where and how should that work be posted?”
Examples:
- WPF UI thread context
- WinForms UI thread context
- old ASP.NET request context
- custom test/framework contexts
- no custom context at all
The important method is effectively:
context.Post(callback, state);It is a scheduling abstraction, usually for affinity-sensitive environments.
how it is captured
By default, await captures the current context/scheduler information so continuation can resume in the “expected” environment.
In simplified terms:
- if a current
SynchronizationContextexists and is meaningful, use it - otherwise often fall back to current
TaskScheduleror ThreadPool semantics
For UI apps, that means:
- before
await, you are on UI thread - after
await, continuation is posted back to UI thread
That is why UI code can safely update controls after await.
how continuation is scheduled back
UI thread case
A WPF SynchronizationContext posts the continuation back to the UI dispatcher thread.
So:
await Task.Delay(1000);
label.Text = "Done";The continuation is scheduled onto the UI thread, not random ThreadPool thread.
server / ThreadPool case
If there is no special UI/request context, continuation usually runs on a ThreadPool worker.
Important: not necessarily the same worker as before.
ConfigureAwait(false)
ConfigureAwait(false) tells the awaiter: “do not capture the current context for this await.”
So:
await SomeOperationAsync().ConfigureAwait(false);means:
- resume wherever convenient
- usually ThreadPool
- do not marshal back to UI/request context
Benefits:
- less context capture overhead
- avoids deadlocks in some sync-over-async situations
- good default in lower-level libraries that do not need caller affinity
But:
- after it, you cannot assume UI thread affinity
- in application code, especially UI code, it may be wrong if you need original context
why deadlocks happen in UI apps
Classic case:
public string GetData()
{
return GetDataAsync().Result;
}And inside:
public async Task<string> GetDataAsync()
{
await SomeIoAsync(); // captures UI context
return "done";
}What happens:
- UI thread calls
GetData() .Resultblocks UI threadGetDataAsynchitsawait- continuation is scheduled back to captured UI context
- UI thread is blocked waiting for result
- continuation cannot run because UI thread is blocked
Deadlock.
This is the important mental model:
- blocking thread waits for async completion
- async completion needs same blocked thread to resume
That is the core sync-over-async deadlock.
ConfigureAwait(false) can break this in library code by preventing the continuation from requiring the UI thread, but the deeper fix is: do not block on async.
PART 6 — THREADPOOL & SCHEDULING
how ThreadPool works
High-level but accurate model:
The .NET ThreadPool is a shared worker pool with:
- global queues
- local per-thread work-stealing queues
- heuristics for thread injection
- logic to balance throughput and avoid excessive thread creation
When work is queued:
- a free worker may pick it up
- otherwise queued work waits
- runtime may add workers depending on load, blocking, and heuristics
The pool tries to avoid:
- too many threads -> context switch overhead
- too few threads -> starvation and low throughput
work stealing
Each worker may keep a local queue for efficiency. Workers prefer local work for cache locality and reduced contention. If a worker runs out of work, it can steal from another worker’s queue.
This improves scalability under many small tasks.
queueing tasks
Task.Run typically queues work to the ThreadPool.
Example:
Task.Run(() => Compute());This means:
- create task
- queue delegate
- a ThreadPool worker executes it
- task completes with result/exception
This is different from I/O async.
how async IO differs from thread usage
This is the key runtime point.
For real async I/O:
- .NET asks OS to start operation
- managed thread returns to pool or caller
- no thread sits blocked for the whole wait
- OS signals completion later
- continuation gets scheduled
For example, a socket read:
- not “one thread waiting in background”
- instead “OS tracks operation; thread resumes only when needed”
So again: async != new thread
It often means fewer blocked threads, not more threads.
PART 7 — EXCEPTION & CANCELLATION FLOW
how exceptions propagate through async/await
Inside async methods, exceptions behave in two phases.
before first incomplete await
If exception happens before actual suspension, the async method may fault its returned task very quickly.
after suspension
If exception happens after resumption, it is captured into the task and rethrown when awaited.
Example:
public async Task<int> FooAsync()
{
await Task.Delay(1);
throw new InvalidOperationException();
}The exception is stored in the task. When caller does:
await FooAsync();the awaiter calls GetResult(), which rethrows the original exception, not usually an AggregateException.
how AggregateException differs
await
await unwraps and rethrows the original exception.
.Wait() or .Result
These synchronous Task APIs typically throw AggregateException.
So:
await task;usually gives:
InvalidOperationException
But:
task.Wait();
var x = task.Result;often gives:
AggregateException
That is why async-first code has cleaner exception flow.
CancellationToken integration
CancellationToken is cooperative cancellation, not forced termination.
Typical flow:
- token is passed into async APIs
- operation observes token
- if cancellation requested, operation throws
OperationCanceledExceptionorTaskCanceledException - task ends in Canceled state if associated correctly
Important:
- token does not kill threads
- token is a signal
- code must honor it
Example:
await Task.Delay(5000, token);If token is canceled:
- delay completes as canceled
- await throws cancellation exception
- caller can catch it
PART 8 — PERFORMANCE CHARACTERISTICS
allocations in async methods
Async has real overhead.
Possible costs:
- state machine
- Task object
- captured locals lifted into state machine
- continuation delegates
- ExecutionContext capture
- closure allocations around async usage
Not every async method allocates equally, but it is not free.
state machine overhead
Every async method that suspends needs resumable state:
- integer state field
- lifted locals
- awaiter storage
- builder field
If method completes synchronously, some paths are cheaper. If it frequently suspends, state-machine cost becomes more visible.
In hot paths, tiny async methods can create measurable overhead.
ValueTask vs Task
ValueTask<T> exists mainly to reduce allocations when operations often complete synchronously.
Example good case:
- cached result
- buffer already available
- lock-free fast path
- socket/pipeline internals
Why cheaper:
- can avoid allocating a new
Task<T>when result is already known
But it is more complex:
- can only be awaited safely in intended ways
- should not be awaited multiple times unless backed appropriately
- harder API surface
- misuse can be subtle
Rule:
- default to
Task - use
ValueTaskonly when measurement justifies it
when async is expensive
Async becomes relatively expensive when:
- work is tiny and very frequent
- method often completes synchronously but still uses async state machine
- hot inner loops use async unnecessarily
- CPU-bound work is wrapped in fake async with no real wait
- too many tiny continuations fragment execution
For example, an async method that just computes a small value and returns immediately may cost more than a synchronous one.
Bad:
public async Task<int> AddAsync(int a, int b)
{
return a + b;
}Better:
public Task<int> AddAsync(int a, int b)
{
return Task.FromResult(a + b);
}Or maybe just make it synchronous.
PART 9 — COMMON LOW-LEVEL PITFALLS
1. sync-over-async deadlocks
Already covered, but the root issue is:
- blocking thread waits
- continuation needs blocked thread/context
Very common in:
- WPF
- WinForms
- legacy ASP.NET
- tests with custom sync context
2. context capture overhead
Default await may capture context and execution context. That costs something.
In low-level libraries with no affinity requirement:
ConfigureAwait(false)often reduces unnecessary marshaling
In high-throughput systems, repeated needless capture can matter.
3. thread starvation
If ThreadPool threads are blocked by:
.Result.Wait()- long synchronous I/O
- locks around async boundaries
- CPU-heavy work mixed carelessly
then continuations and queued work may wait too long.
Symptoms:
- system “looks async” but stalls
- requests pile up
- timers and continuations delayed
- throughput collapses under load
Async reduces blocked threads only if you avoid blocking patterns.
4. unobserved task exceptions
If a task faults and nobody awaits or observes it, the exception can go unnoticed for too long.
async void is especially dangerous because:
- exceptions escape to synchronization context
- caller cannot await completion
- composition is hard
- failure handling is poor
Use async void only for event handlers.
For fire-and-forget background work, explicitly observe/log failures.
PART 10 — SENIOR ENGINEER MENTAL MODEL
how to reason about async execution step-by-step
Use this mental checklist:
1. What does this method return?
Task/Task<T>/ValueTask<T>means completion handle, not thread
2. Where is the actual waiting?
- network?
- disk?
- timer?
- ThreadPool CPU work?
- manual completion via callback?
3. Does await complete synchronously or suspend?
This changes behavior a lot.
4. Is context captured?
- UI context?
- thread pool?
ConfigureAwait(false)?
5. What thread resumes continuation?
Not “what thread started it,” but “what scheduler/context resumes it?”
6. What happens on failure?
- faulted task?
- rethrow on await?
- hidden fire-and-forget?
7. Is any thread being blocked?
This is the biggest practical question.
how to debug async issues mentally
When something is wrong, ask in this order:
A. Is there blocking?
Look for:
.Result.Wait().GetAwaiter().GetResult()- synchronous locks around async work
B. Is continuation waiting for a context?
Especially UI or legacy request contexts.
C. Is there ThreadPool starvation?
Look for too many blocked pool threads or CPU saturation.
D. Is the task actually completing?
Maybe callback never fires, token never cancels, TCS never completes.
E. Is exception hidden in a task nobody awaited?
Very common in background flows.
F. Is this really async I/O, or fake async wrapping sync work?
Important performance/debug distinction.
how to decide when to use async vs sync
Use async when:
- operation waits on I/O
- scalability matters
- responsiveness matters
- you need cancellation/timeouts/composition across waits
Use sync when:
- operation is trivial and immediate
- there is no real waiting
- hot path overhead matters
- API is fundamentally local/CPU-only and simple
Use parallel/background CPU work when:
- the real need is computation, not waiting
The senior-level rule is not “make everything async.” It is:
Use async where waiting would otherwise waste a thread or hurt responsiveness. Use sync where async adds ceremony and overhead without benefit.
Final interview-ready mental summary
async/await in .NET is basically this:
- the compiler turns your method into a resumable state machine
- the builder exposes a
Taskrepresenting completion awaitchecks whether the awaited operation is already done- if not done, it stores state, registers continuation, and returns
- later, completion schedules
MoveNext()again - context capture decides where continuation resumes
- no new thread is implied
- real async I/O often uses no waiting managed thread at all
- most bugs come from blocking, context assumptions, starvation, or hidden exceptions
For interviews, the strongest sentence is:
A Task is a completion abstraction, async/await is compiler-generated continuation machinery, and the real runtime behavior depends on whether the awaited operation is already complete, what context was captured, and how completion is scheduled.
If you want, I can turn this into a second pass focused only on: “async/await interview questions with strong senior-level answers and follow-up questions.”
Let’s go deep on async void — this is one of those topics where senior engineers are expected to have very precise mental models, because misuse causes real production issues.
PART 1 — WHAT async void REALLY IS
Normally, async methods return:
TaskTask<T>ValueTask<T>
But async void is different:
public async void DoSomething()
{
await Task.Delay(1000);
}This is not awaitable and not composable.
Key reality:
async voidmeans: “fire this async operation and don’t give the caller any way to observe or control it.”
PART 2 — WHY async void EXISTS
It exists for one specific reason only:
Event handlers
In .NET, many event delegates require void:
void OnClick(object sender, EventArgs e)So if you want to use await inside:
private async void Button_Click(object sender, EventArgs e)
{
await LoadDataAsync();
}You have no choice — the signature must return void.
Why not change events to Task?
Because:
- .NET events are synchronous delegate invocations
- they were designed long before async/await
- changing them would break the entire ecosystem
So async void exists as a bridge between old synchronous delegate model and async code.
PART 3 — WHAT MAKES async void DIFFERENT INTERNALLY
The key difference is:
async Task
- uses
AsyncTaskMethodBuilder - creates a
Task - exceptions are stored in the Task
- caller can await and observe completion
async void
- uses
AsyncVoidMethodBuilder - no Task is created
- completion is not observable
- exceptions are raised directly to the context
This is the critical difference:
async voidhas no external handle to track execution.
So:
- no awaiting
- no error propagation via Task
- no composition (
Task.WhenAll, etc.)
PART 4 — EXCEPTION BEHAVIOR (THE MOST IMPORTANT PART)
This is where things get dangerous.
async Task
await FooAsync();If FooAsync throws:
- exception is captured into the Task
- rethrown at await point
Safe, predictable.
async void
async void Foo()
{
throw new Exception("boom");
}What happens?
👉 The exception is NOT stored anywhere 👉 It is raised directly on the SynchronizationContext
Behavior depends on environment:
UI (WPF / WinForms)
- exception goes to UI thread
- typically crashes the app
ASP.NET (old)
- may crash request or app domain
Console / ThreadPool
- goes to
SynchronizationContextorTaskScheduler.UnobservedTaskException - can terminate process
Mental model:
async voidexceptions are “unhandled exceptions”, not “Task failures”.
This is why it is dangerous.
PART 5 — NO COMPOSITION
With Task, you can do:
await Task.WhenAll(tasks);
await Task.WhenAny(tasks);With async void:
Foo(); // no way to waitYou cannot:
- wait for completion
- combine with other async work
- enforce ordering
- implement retries
- implement timeouts
Real production problem
async void SendEmail()
{
await smtp.SendAsync();
}Caller:
SendEmail();
// assumes email sentReality:
- it may fail silently
- caller cannot know
- system becomes inconsistent
PART 6 — TESTABILITY PROBLEM
async void breaks testing.
Example:
[Test]
public void TestSomething()
{
Foo(); // async void
}Test:
- finishes immediately
- async work still running
- test passes even if it fails later
This leads to:
- flaky tests
- hidden bugs
- race conditions
PART 7 — FIRE-AND-FORGET MISCONCEPTION
Many developers use async void like this:
async void DoBackgroundWork()
{
await SomethingAsync();
}This is wrong.
Why?
Because:
- no error handling
- no retry
- no cancellation
- no logging guarantee
- no lifecycle control
Correct mental model:
Fire-and-forget still needs observability.
PART 8 — WHAT TO DO INSTEAD
1. Always prefer Task
Instead of:
async void Process()Use:
async Task Process()2. For event handlers → isolate logic
private async void Button_Click(object sender, EventArgs e)
{
await HandleClickAsync();
}
private async Task HandleClickAsync()
{
await DoWorkAsync();
}Why:
- event handler must be
void - real logic is testable and composable
3. Safe fire-and-forget pattern
If you truly need fire-and-forget:
_ = Task.Run(async () =>
{
try
{
await DoWorkAsync();
}
catch (Exception ex)
{
Log(ex);
}
});Or better:
public static void FireAndForget(Task task, ILogger logger)
{
task.ContinueWith(t =>
{
logger.LogError(t.Exception, "Background task failed");
}, TaskContinuationOptions.OnlyOnFaulted);
}4. Use background services (production systems)
In real systems (like your interview scenario):
Use:
IHostedService- message queues
- worker services
Instead of random async void
5. For UI responsiveness
Still use:
async void Button_Click(...)But:
- keep it thin
- delegate to
Taskmethods - handle exceptions explicitly if needed
PART 9 — WHEN async void IS ACTUALLY OK
Only acceptable cases:
1. Event handlers
async void OnClick(...)2. Top-level fire-and-forget WITH explicit error handling
Very rare, and must be controlled.
Everything else → ❌ avoid
PART 10 — SENIOR ENGINEER MENTAL MODEL
The key insight
async voidremoves the contract of async.
With Task, you have:
- completion
- error
- cancellation
- composition
With async void, you lose all of that.
Think in terms of contracts
When you design async APIs:
Task= “I promise I will complete and you can observe me”async void= “I’m doing something, good luck”
Interview-level answer (strong)
If asked:
“When should you use async void?”
You answer:
Only for event handlers. Because async void methods:
- cannot be awaited
- do not propagate exceptions through Task
- cannot be composed or tested
- raise exceptions directly on the SynchronizationContext
In all other cases, async methods should return
TaskorTask<T>so the caller can observe completion, handle errors, and compose operations.
One-liner to remember
async voidis for events. Everything else should returnTask.
If you want next step, I can go deeper into:
👉 ExecutionContext vs SynchronizationContext (very commonly confused, very interview-relevant)