Skip to content

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 resource
  • ThreadPool = pool of reusable execution resources
  • Task = promise/future representing work or completion

synchronous vs asynchronous vs parallel

Synchronous

The caller waits until the operation finishes before moving on.

csharp
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.

csharp
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.

csharp
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:

  1. a promise to the consumer
  2. 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.

csharp
var tcs = new TaskCompletionSource<int>();
Task<int> task = tcs.Task;

tcs.SetResult(42);

This is important because it shows clearly that:

  • a Task does not require a dedicated executing thread
  • a Task can 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:

csharp
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:

csharp
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()
  • IsCompleted
  • OnCompleted(...) or UnsafeOnCompleted(...)
  • GetResult()

So conceptually:

csharp
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.

csharp
await SomeOperationAsync();

Step 1: call the async method

SomeOperationAsync() returns a task-like object.

Step 2: get the awaiter

Compiler-generated code does:

csharp
var awaiter = task.GetAwaiter();

Step 3: check completion

It checks:

csharp
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:

csharp
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
  • TaskCompletionSource was 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:

csharp
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 SynchronizationContext exists and is meaningful, use it
  • otherwise often fall back to current TaskScheduler or 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:

csharp
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:

csharp
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:

csharp
public string GetData()
{
    return GetDataAsync().Result;
}

And inside:

csharp
public async Task<string> GetDataAsync()
{
    await SomeIoAsync(); // captures UI context
    return "done";
}

What happens:

  1. UI thread calls GetData()
  2. .Result blocks UI thread
  3. GetDataAsync hits await
  4. continuation is scheduled back to captured UI context
  5. UI thread is blocked waiting for result
  6. 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:

csharp
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:

csharp
public async Task<int> FooAsync()
{
    await Task.Delay(1);
    throw new InvalidOperationException();
}

The exception is stored in the task. When caller does:

csharp
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:

csharp
await task;

usually gives:

  • InvalidOperationException

But:

csharp
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 OperationCanceledException or TaskCanceledException
  • task ends in Canceled state if associated correctly

Important:

  • token does not kill threads
  • token is a signal
  • code must honor it

Example:

csharp
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 ValueTask only 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:

csharp
public async Task<int> AddAsync(int a, int b)
{
    return a + b;
}

Better:

csharp
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 Task representing completion
  • await checks 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:

  • Task
  • Task<T>
  • ValueTask<T>

But async void is different:

csharp
public async void DoSomething()
{
    await Task.Delay(1000);
}

This is not awaitable and not composable.

Key reality:

async void means: “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:

csharp
void OnClick(object sender, EventArgs e)

So if you want to use await inside:

csharp
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 void has 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

csharp
await FooAsync();

If FooAsync throws:

  • exception is captured into the Task
  • rethrown at await point

Safe, predictable.


async void

csharp
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 SynchronizationContext or TaskScheduler.UnobservedTaskException
  • can terminate process

Mental model:

async void exceptions are “unhandled exceptions”, not “Task failures”.

This is why it is dangerous.


PART 5 — NO COMPOSITION

With Task, you can do:

csharp
await Task.WhenAll(tasks);
await Task.WhenAny(tasks);

With async void:

csharp
Foo(); // no way to wait

You cannot:

  • wait for completion
  • combine with other async work
  • enforce ordering
  • implement retries
  • implement timeouts

Real production problem

csharp
async void SendEmail()
{
    await smtp.SendAsync();
}

Caller:

csharp
SendEmail();
// assumes email sent

Reality:

  • it may fail silently
  • caller cannot know
  • system becomes inconsistent

PART 6 — TESTABILITY PROBLEM

async void breaks testing.

Example:

csharp
[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:

csharp
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:

csharp
async void Process()

Use:

csharp
async Task Process()

2. For event handlers → isolate logic

csharp
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:

csharp
_ = Task.Run(async () =>
{
    try
    {
        await DoWorkAsync();
    }
    catch (Exception ex)
    {
        Log(ex);
    }
});

Or better:

csharp
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:

csharp
async void Button_Click(...)

But:

  • keep it thin
  • delegate to Task methods
  • handle exceptions explicitly if needed

PART 9 — WHEN async void IS ACTUALLY OK

Only acceptable cases:

1. Event handlers

csharp
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 void removes 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 Task or Task<T> so the caller can observe completion, handle errors, and compose operations.


One-liner to remember

async void is for events. Everything else should return Task.


If you want next step, I can go deeper into:

👉 ExecutionContext vs SynchronizationContext (very commonly confused, very interview-relevant)

Docs-first project memory for AI-assisted implementation.