Skip to content

Async / Await Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

C# async/await interview questions — state machines, SynchronizationContext, ConfigureAwait, deadlocks, ValueTask, and CancellationToken.

Read the in-depth guideHow C# Async/Await Works Under the Hood(opens in new tab)
15 of 15

async marks a method so that await can be used inside it. await suspends the method without blocking the thread — control returns to the caller and resumes when the awaited Task completes. The compiler transforms the method into a state machine: a struct that tracks which await the method is currently paused at and resumes from the correct point when the Task signals completion.

public async Task<string> FetchAsync(string url)
{
    // Thread is released here; resumes on completion of GetAsync
    HttpResponseMessage response = await httpClient.GetAsync(url);

    // Thread is released here; resumes when ReadAsStringAsync finishes
    string body = await response.Content.ReadAsStringAsync();

    return body; // Task<string> is completed with this value
}

The generated state machine roughly:

  1. Calls GetAsync, registers a continuation on the returned Task.
  2. Returns to the caller immediately (the method returns an incomplete Task<string>).
  3. When GetAsync finishes, the continuation runs — resuming after the first await.
  4. Repeats for ReadAsStringAsync, then completes the outer Task with the body.

Rule of thumb: async / await is syntactic sugar over Task continuations. It lets you write asynchronous code that reads like synchronous code without blocking threads.

A Thread is an OS-level execution resource. Creating one allocates a stack (~1 MB) and involves a kernel transition — expensive. A Task is a higher-level abstraction representing a unit of work that may or may not require a dedicated thread.

// Thread — dedicated OS thread, always uses a thread
var thread = new Thread(() => Console.WriteLine("thread"));
thread.Start();

// Task — scheduled on the ThreadPool; may reuse threads
var task = Task.Run(() => Console.WriteLine("task on pool thread"));

// async Task — may use zero threads while awaiting I/O
var ioTask = FetchAsync("https://example.com");
// While awaiting the network, NO thread is consumed

Key distinctions:

  • Thread is 1-to-1 with an OS thread. Task is a promise of a result.
  • I/O-bound async Tasks consume no thread while waiting — they rely on OS completion ports / epoll callbacks.
  • CPU-bound work still needs a thread; Task.Run puts it on the ThreadPool.
  • ThreadPool threads are reused; creating raw Threads for short-lived work wastes resources.

Rule of thumb: Use Task / async-await for I/O-bound work. Use Task.Run to offload CPU-bound work to the ThreadPool. Only use raw Thread when you need precise control over thread identity, apartment state, or priority.

By default, after an await, C# tries to resume on the original SynchronizationContext (e.g., the UI thread in WPF/WinForms, or the ASP.NET Classic request context). ConfigureAwait(false) tells the awaiter not to capture the context — the continuation can run on any ThreadPool thread.

// Library code — does NOT need to return to a specific context:
public async Task<Data> GetDataAsync()
{
    var raw = await httpClient.GetStringAsync(url).ConfigureAwait(false);
    // Continuation runs on a pool thread — no context marshalling overhead
    return Parse(raw);
}

// Application code — DOES need UI context (e.g., updating a Label):
private async void Button_Click(object sender, EventArgs e)
{
    var data = await GetDataAsync(); // resume on UI thread — default behaviour
    label.Text = data.Name;          // safe: we're on the UI thread
}

Benefits of ConfigureAwait(false):

  • Performance: avoids context-switch overhead.
  • Deadlock prevention: a common deadlock occurs when library code awaits without ConfigureAwait(false) and the caller blocks with .Result on a single-threaded context — the continuation never runs because the context is blocked.

In ASP.NET Core there is no SynchronizationContext, so ConfigureAwait(false) has no functional effect — but it is still best practice in library code for portability.

Rule of thumb: Use ConfigureAwait(false) in all library / reusable code. Omit it in application code that needs to update UI after the await.

The classic deadlock: a single-threaded SynchronizationContext (UI thread, or ASP.NET Classic) blocks waiting for a Task to finish (.Result / .Wait()), while the Task's continuation needs that same context to resume — so it can never run.

// Deadlock scenario (WinForms, WPF, or old ASP.NET):
public string GetData()
{
    // .Result BLOCKS the current (UI) thread:
    return FetchAsync().Result; // <- deadlock!
}

public async Task<string> FetchAsync()
{
    await Task.Delay(100); // default ConfigureAwait(true)
    // Continuation tries to resume on the UI thread — but it's BLOCKED by .Result!
    return "done";
}

// Fix option 1: go async all the way:
public async Task<string> GetDataAsync() => await FetchAsync();

// Fix option 2: break context capture in the library:
public async Task<string> FetchAsync()
{
    await Task.Delay(100).ConfigureAwait(false); // no context needed
    return "done";
}

ASP.NET Core has no SynchronizationContext, so .Result does not deadlock there — but it still blocks a ThreadPool thread unnecessarily, harming scalability.

Rule of thumb: Never call .Result, .Wait(), or GetAwaiter().GetResult() on a Task in code that might run on a single-threaded context. Go async all the way.

Task.WhenAll returns a Task that completes when all provided Tasks finish. Task.WhenAny returns a Task that completes when any one of the provided Tasks finishes (the rest keep running).

var t1 = FetchUserAsync(1);   // 200 ms
var t2 = FetchUserAsync(2);   // 150 ms
var t3 = FetchUserAsync(3);   // 300 ms

// WhenAll — waits for ALL; total time ≈ 300 ms (longest)
User[] users = await Task.WhenAll(t1, t2, t3);

// If any task faults, WhenAll re-throws the FIRST exception;
// use the Task objects directly to inspect all exceptions:
var tasks = new[] { t1, t2, t3 };
await Task.WhenAll(tasks);
// foreach (var t in tasks) if (t.IsFaulted) { ... t.Exception ... }

// WhenAny — completes when the FIRST task finishes; total time ≈ 150 ms
Task<User> first = await Task.WhenAny(t1, t2, t3);
Console.WriteLine($"First result: {first.Result.Name}");

// Timeout pattern with WhenAny:
var work   = DoExpensiveWorkAsync();
var timeout = Task.Delay(TimeSpan.FromSeconds(5));
if (await Task.WhenAny(work, timeout) == timeout)
    throw new TimeoutException("Operation exceeded 5 s");
var result = await work;

Rule of thumb: Use WhenAll to run independent tasks in parallel and collect all results. Use WhenAny for timeouts, racing alternatives, or processing results as they arrive.

async void is a method signature that returns void instead of Task. Callers cannot await it, cannot observe its completion, and — critically — exceptions escape to the SynchronizationContext, not to the caller, crashing the process.

// async void — caller cannot catch the exception
async void LoadDataBad()
{
    await Task.Delay(100);
    throw new InvalidOperationException("boom"); // crashes the app!
}

// Caller:
try
{
    LoadDataBad(); // fire and forget — can't await, exception is NOT caught here
}
catch (Exception) { } // never catches the async exception!

// Correct: return Task and await it
async Task LoadDataGood()
{
    await Task.Delay(100);
    throw new InvalidOperationException("boom");
}

try
{
    await LoadDataGood(); // exception propagates correctly here
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message); // "boom" — caught!
}

The only legitimate use of async void is event handlers, where the event contract requires void:

private async void Button_Click(object sender, EventArgs e)
{
    // Wrap in try/catch to handle exceptions manually
    try { await DoWorkAsync(); }
    catch (Exception ex) { ShowError(ex); }
}

Rule of thumb: Never write async void outside of event handlers. Always return Task or Task<T> so callers can await and observe exceptions.

ValueTask<T> is a struct that wraps either a synchronously available result or a reference to a Task<T>. Unlike Task<T> (a class), it avoids a heap allocation when the result is available synchronously — common in hot code paths like caches or buffer reads.

// Every call allocates a Task<T> object on the heap:
public async Task<int> GetCountTaskAsync()
{
    if (_cache.TryGetValue("count", out int v)) return v; // still allocates Task!
    return await FetchCountFromDbAsync();
}

// ValueTask: zero allocation on the fast (cache-hit) path:
public ValueTask<int> GetCountValueTaskAsync()
{
    if (_cache.TryGetValue("count", out int v))
        return new ValueTask<int>(v); // struct, no heap allocation
    return new ValueTask<int>(FetchCountFromDbAsync());
}

Restrictions on ValueTask<T> (unlike Task<T>):

  • Await only once — a ValueTask must not be awaited more than once.
  • Do not call .Result before the task completes.
  • Do not use in concurrent scenarios — one consumer only.
// Correct usage:
var result = await GetCountValueTaskAsync(); // await once, done

// Wrong — multi-await:
var vt = GetCountValueTaskAsync();
var a = await vt; // ok
var b = await vt; // undefined behaviour!

Rule of thumb: Default to Task<T>. Use ValueTask<T> only when profiling shows that the synchronous-result fast path is frequent enough that the allocation overhead matters.

CancellationToken is the standard .NET mechanism for cooperative cancellation. A CancellationTokenSource issues tokens; calling .Cancel() on the source signals all tokens issued from it. Receivers check the token and throw OperationCanceledException.

// Caller: create source and pass token
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10)); // auto-cancel after 10 s

try
{
    await DoWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Cancelled");
}

// Method: accept token and forward it
public async Task DoWorkAsync(CancellationToken ct = default)
{
    // Pass to awaitable calls — they throw OperationCanceledException on cancel
    await Task.Delay(5000, ct);

    // Manual check in CPU-bound loops:
    for (int i = 0; i < 1_000_000; i++)
    {
        ct.ThrowIfCancellationRequested(); // throws OperationCanceledException
        Process(i);
    }

    // Registration callback (for non-cancellable APIs):
    using var reg = ct.Register(() => socket.Close());
    await socket.ReceiveAsync(buffer);
}

Rule of thumb: Accept CancellationToken ct = default in every async method and forward it to every awaitable call. Never swallow OperationCanceledException — let it propagate so the caller knows the operation was cancelled.

SynchronizationContext is an abstraction that lets code schedule work on a specific thread or context. The await machinery captures the current SynchronizationContext before suspending and posts the continuation back to it on resume — ensuring UI updates happen on the UI thread.

// WPF SynchronizationContext routes continuations to the Dispatcher (UI thread)
private async void Button_Click(object sender, EventArgs e)
{
    // Currently on UI thread; SynchronizationContext = DispatcherSynchronizationContext
    var data = await FetchDataAsync();
    // Resumed on UI thread — safe to update UI
    label.Content = data;
}

// Console apps and ASP.NET Core have SynchronizationContext.Current == null
// Continuations run on any ThreadPool thread

ConfigureAwait(false) bypasses the context:

public async Task<string> LibraryMethodAsync()
{
    // Does NOT capture the context; continuation may run on any pool thread
    var result = await httpClient.GetStringAsync(url).ConfigureAwait(false);
    return result; // fine — no UI/context operations needed here
}

Rule of thumb: Library code should always use ConfigureAwait(false) so it does not accidentally pin continuations to a specific context. Application code that updates UI or uses request-scoped services should NOT use ConfigureAwait(false).

Exceptions thrown in an async Task method are stored in the returned Task and re-thrown when the Task is awaited. If you never await the Task, the exception is unobserved.

public async Task FaultyAsync()
{
    await Task.Delay(10);
    throw new InvalidOperationException("failed");
}

// Correct: await and catch
try
{
    await FaultyAsync();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message); // "failed"
}

// WhenAll re-throws the FIRST exception; inspect all via AggregateException:
var t1 = ThrowAsync("err1");
var t2 = ThrowAsync("err2");
try
{
    await Task.WhenAll(t1, t2);
}
catch
{
    // t1.Exception and t2.Exception hold the individual exceptions
    foreach (var t in new[] { t1, t2 })
        if (t.IsFaulted)
            Console.WriteLine(t.Exception!.InnerException!.Message);
}

For fire-and-forget patterns, always log unobserved exceptions:

_ = DoWorkAsync().ContinueWith(
    t => logger.LogError(t.Exception, "Background task failed"),
    TaskContinuationOptions.OnlyOnFaulted);

Rule of thumb: Always await Tasks. If you must fire-and-forget, attach a .ContinueWith error handler so exceptions are never silently swallowed.

Both schedule a delegate on the ThreadPool, but Task.Run has safer defaults and should be the default choice. Task.Factory.StartNew gives more control but is error-prone.

// Task.Run — the recommended way to offload CPU work:
var result = await Task.Run(() => ExpensiveCpuWork());

// Task.Factory.StartNew — returns Task<Task> for async delegates!
// You MUST Unwrap() or you await the outer task, not the inner one:
var badTask = Task.Factory.StartNew(async () =>
{
    await Task.Delay(1000);
    return 42;
});
// badTask is Task<Task<int>> — awaiting it gives Task<int>, not 42!
var result2 = await await badTask; // double-await needed!

// Task.Run handles async delegates correctly (implicitly unwraps):
var goodTask = Task.Run(async () =>
{
    await Task.Delay(1000);
    return 42;
});
int value = await goodTask; // 42 — correct

Task.Factory.StartNew options you might still need:

  • TaskCreationOptions.LongRunning — hints to use a dedicated thread (not pool).
  • TaskScheduler — run on a custom scheduler.

Rule of thumb: Always use Task.Run for offloading CPU-bound work to the ThreadPool. Use Task.Factory.StartNew only when you need options not available in Task.Run (long-running, custom scheduler).

IAsyncEnumerable<T> (C# 8 / .NET Core 3) is the async equivalent of IEnumerable<T> — a stream of values that are produced asynchronously one at a time. Unlike returning Task<IEnumerable<T>> (which waits for all items before yielding any), IAsyncEnumerable<T> streams items as they become available.

// Producer: async iterator method
public async IAsyncEnumerable<int> GenerateAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    for (int i = 0; i < 100; i++)
    {
        await Task.Delay(50, ct);  // async work per item
        yield return i;            // yield each item as ready
    }
}

// Consumer: await foreach
await foreach (var value in GenerateAsync(cancellationToken))
{
    Console.WriteLine(value); // processes each item as it arrives
}

// Real-world: streaming large DB results with EF Core
public async IAsyncEnumerable<Order> StreamOrdersAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var order in dbContext.Orders
        .Where(o => o.Status == "Pending")
        .AsAsyncEnumerable()
        .WithCancellation(ct))
    {
        yield return order;
    }
}

Rule of thumb: Use IAsyncEnumerable<T> when producing a large or unbounded stream where you want to process or forward items before the entire sequence is ready — large database results, real-time feeds, or paginated API calls.

The standard approach uses CancellationTokenSource.CancelAfter or Task.WhenAny with Task.Delay. Each has trade-offs in resource cleanup and exception semantics.

// Option 1: CancellationTokenSource.CancelAfter (preferred)
public async Task<string> FetchWithTimeoutAsync(string url, int timeoutMs)
{
    using var cts = new CancellationTokenSource(timeoutMs);
    try
    {
        return await httpClient.GetStringAsync(url, cts.Token);
    }
    catch (OperationCanceledException) when (cts.IsCancellationRequested)
    {
        throw new TimeoutException($"Request to {url} exceeded {timeoutMs} ms");
    }
}

// Option 2: Task.WhenAny with Task.Delay (when the API does not accept a token)
public async Task<string> FetchWithWhenAnyAsync(string url, int timeoutMs)
{
    var workTask    = httpClient.GetStringAsync(url);
    var timeoutTask = Task.Delay(timeoutMs);

    if (await Task.WhenAny(workTask, timeoutTask) == timeoutTask)
        throw new TimeoutException($"Request exceeded {timeoutMs} ms");

    return await workTask; // already completed
}

// Note: WhenAny does NOT cancel the workTask — it keeps running in the background.
// Prefer Option 1 so the underlying operation is truly stopped on timeout.

// Option 3: CancellationTokenSource.CreateLinkedTokenSource to merge timeouts
public async Task<string> FetchAsync(string url, CancellationToken ct)
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    using var linkedCts  = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
    return await httpClient.GetStringAsync(url, linkedCts.Token);
}

Rule of thumb: Always prefer CancellationTokenSource with CancelAfter over Task.WhenAny + Task.Delay. The token approach actually cancels the underlying operation; WhenAny just abandons it, leaving the work running and the connection open.

TaskCompletionSource<T> lets you manually control when a Task<T> completes, faults, or is cancelled. It bridges callback-based or event-based APIs into the Task-based async world.

// Bridge a callback API to Task:
public Task<string> ReadLineAsync(Socket socket)
{
    var tcs = new TaskCompletionSource<string>();

    socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, ar =>
    {
        try
        {
            int bytes = socket.EndReceive(ar);
            tcs.SetResult(Encoding.UTF8.GetString(buffer, 0, bytes)); // signals completion
        }
        catch (Exception ex)
        {
            tcs.SetException(ex); // signals fault
        }
    }, null);

    return tcs.Task; // caller awaits this — completes when callback fires
}

// Gate pattern — signal readiness from outside:
public class ReadyGate
{
    private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
    public Task WaitAsync() => _tcs.Task;
    public void Signal()   => _tcs.TrySetResult(); // complete the gate
}

var gate = new ReadyGate();
_ = Task.Run(async () =>
{
    await Task.Delay(500);
    gate.Signal(); // unblocks all waiters
});
await gate.WaitAsync(); // blocks until gate is signalled

// Note: TaskCreationOptions.RunContinuationsAsynchronously prevents
// SetResult from running continuations synchronously on the caller's stack.

Rule of thumb: Use TaskCompletionSource<T> to wrap legacy callback or event APIs into awaitable Tasks. Always pass TaskCreationOptions.RunContinuationsAsynchronously to avoid deadlocks from continuations running inline inside the callback.

IProgress<T> is the standard interface for async progress reporting. Progress<T> captures the SynchronizationContext at construction time and marshals calls to Report back to that context (e.g., the UI thread), so subscribers can safely update UI.

// Method: accept IProgress<T> and call Report as work advances
public async Task DownloadFilesAsync(
    IEnumerable<string> urls,
    IProgress<int>? progress = null,
    CancellationToken ct = default)
{
    var list = urls.ToList();
    for (int i = 0; i < list.Count; i++)
    {
        await DownloadOneAsync(list[i], ct);
        progress?.Report(i + 1); // report count completed so far
    }
}

// Caller in a WPF / WinForms app — Progress<T> marshals to UI thread:
var progressHandler = new Progress<int>(count =>
{
    // This runs on the UI thread — safe to update controls:
    progressBar.Value = count;
    statusLabel.Text  = $"Downloaded {count} files";
});

await DownloadFilesAsync(urls, progressHandler, cts.Token);

// Caller in a console app — no SynchronizationContext; runs inline:
var consoleProgress = new Progress<int>(n => Console.Write($"\r{n} done"));
await DownloadFilesAsync(urls, consoleProgress);

// Note: IProgress<T> is accepted as nullable so callers can opt out:
await DownloadFilesAsync(urls); // progress = null, no overhead

Rule of thumb: Accept IProgress<T>? (nullable) in long-running async methods so callers can opt in to progress updates without changing the method signature. Use Progress<T> — not a raw delegate — so progress callbacks are marshalled back to the correct thread automatically.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel