C# Interview Questions for Senior Developers — async/await, GC & Memory, the Type System, and LINQ (Deep Answers)
30 senior C# interview questions with deep answers and real production code: async/await, GC and memory, the type system, LINQ, and concurrency.
- Author
- Randhir Jassal
- Published
- Reading time
- 21 min read
- Views
- 1 views
Junior C# interviews test definitions — "what is a delegate," "value vs reference type." Senior interviews test judgment: not what async is, but why your
.Resultdeadlocked in production; not what a struct is, but when copying one silently doubled your allocations. This guide is 30 questions across the four areas seniors actually get grilled on — async & concurrency, memory & GC, the type system, and LINQ & production patterns — each answered the way you'd answer at a whiteboard.
Every answer follows the same three-part shape: a short answer you could give in one breath, a deep dive into the mechanism (with code), and the senior signal — the specific thing the interviewer is listening for. And every code sample is from a real system: Mattrx, a multi-tenant marketing-analytics SaaS on .NET 9 / ASP.NET Core / Azure SQL / Redis, with a 1.2B-row events table and a CQRS core. No Foo and Bar.
How to use this guide
Read the deep dive even when you already know the short answer — seniority lives in the why and the when-not-to, not the definition. The questions build on each other within each block. If you can explain the code comments out loud, you're ready.
Block A — async/await & concurrency
1. What actually happens when you await a Task?
Short answer: The compiler rewrites your method into a state machine; at the await, the method returns to its caller and the thread goes back to the pool — nothing blocks. When the awaited operation completes, a continuation resumes the rest of the method.
Deep dive: await is not "wait here." It's "suspend here, free the thread, and schedule the rest as a continuation." That's why an ASP.NET Core server can handle thousands of concurrent requests on a small thread pool — threads aren't parked waiting on I/O.
// In Mattrx's dashboard endpoint:
public async Task<CampaignKpis> GetKpisAsync(TenantId tenant, Guid campaignId, DateRange range, CancellationToken ct)
{
// At this await the request thread is RETURNED to the pool while Azure SQL runs the query.
var kpis = await _repository.AggregateAsync(tenant, campaignId, range, ct);
// A pooled thread resumes here when the query completes.
return kpis;
}
await _repository.AggregateAsync(...)
│ suspend — thread returned to the pool (NOT blocked)
▼
...Azure SQL runs the aggregate...
│ completes
▼
continuation resumes on a pooled thread
Senior signal: You say "no thread is blocked during the await" and can explain the state-machine rewrite — not just "it waits for the task."
2. What does ConfigureAwait(false) do, and where do you need it?
Short answer: It tells the continuation not to resume on the captured SynchronizationContext. In library/server code you use it to avoid unnecessary context marshalling (and to sidestep deadlocks); in ASP.NET Core there's no SynchronizationContext, so it's mostly about libraries and legacy sync-over-async.
Deep dive: By default a continuation tries to resume on the context it started on (a UI thread, or historically ASP.NET's request context). In reusable library code you don't want that coupling.
// Mattrx.Infrastructure — a shared library used by API, workers, and tests.
public async Task<byte[]> RenderAsync(ReportSpec spec, CancellationToken ct)
{
var html = await _templateEngine.BuildAsync(spec, ct).ConfigureAwait(false);
return await _pdf.ConvertAsync(html, ct).ConfigureAwait(false);
}
Senior signal: You know ASP.NET Core has no SynchronizationContext, so you don't cargo-cult ConfigureAwait(false) everywhere in app code, but you do use it in shared libraries.
3. Why does calling .Result or .Wait() on an async method deadlock?
Short answer: In a context that captures a SynchronizationContext (classic ASP.NET, WPF), blocking the one context thread with .Result while the continuation needs that same thread to resume is a self-deadlock. The fix is "async all the way," never block.
Deep dive: The continuation is queued back to the captured context; the context thread is blocked inside .Result; the continuation can never run; the Task never completes. It often "works on my machine" (ASP.NET Core has no context) and then deadlocks in a legacy host or a UI app.
// DEADLOCK RISK in a context-capturing host:
public CampaignKpis GetKpis(TenantId t, Guid c, DateRange r)
=> _service.GetKpisAsync(t, c, r, CancellationToken.None).Result; // blocks the context thread
// CORRECT — async all the way:
public Task<CampaignKpis> GetKpisAsync(TenantId t, Guid c, DateRange r, CancellationToken ct)
=> _service.GetKpisAsync(t, c, r, ct);
Senior signal: You explain the mechanism (captured context + blocked thread + queued continuation), not just "don't use .Result."
4. Task vs ValueTask — when would you return ValueTask?
Short answer: Use ValueTask<T> for hot-path methods that often complete synchronously (e.g., a cache hit) to avoid allocating a Task object each call. Otherwise default to Task — ValueTask has sharp edges (never await it twice, never block on it).
Deep dive: Mattrx's KPI lookup hits Redis ~97% of the time and returns synchronously; a Task<T> per call is a needless allocation at thousands of req/sec.
// Returns synchronously on a cache hit — ValueTask avoids the Task allocation.
public async ValueTask<CampaignKpis> GetCachedKpisAsync(string key, CancellationToken ct)
{
if (_local.TryGetValue(key, out var hit)) // synchronous hot path
return hit;
return await LoadAsync(key, ct); // async cold path
}
Senior signal: You name the rule "await a ValueTask exactly once, never .Result it," and you don't sprinkle ValueTask everywhere — only measured hot paths.
5. Why is async void dangerous, and when is it acceptable?
Short answer: An async void method can't be awaited and its exceptions escape to the synchronization context (often crashing the process). The only legitimate use is a top-level event handler.
Deep dive: Exceptions in async Task are captured on the returned Task; in async void there's no Task, so the exception is raised on the context and typically unhandled.
// BUG: fire-and-forget async void — an exception here can crash the host, unobserved.
public async void OnCampaignCreated(CampaignCreated e) => await _search.IndexAsync(e);
// CORRECT: return a Task so failures are observable and awaitable.
public async Task OnCampaignCreatedAsync(CampaignCreated e, CancellationToken ct)
=> await _search.IndexAsync(e, ct);
Senior signal: You immediately flag "async void = unobservable exceptions" and mention event handlers as the sole exception.
6. How do you run many async operations with bounded concurrency?
Short answer: Don't Task.WhenAll 10,000 tasks unbounded — throttle with a SemaphoreSlim or use Parallel.ForEachAsync with MaxDegreeOfParallelism.
Deep dive: Mattrx fans out per-tenant report jobs; firing them all at once would exhaust the DB connection pool and hammer downstream services.
// Bounded fan-out: at most 8 tenants processed concurrently.
await Parallel.ForEachAsync(
tenants,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (tenant, token) => await GenerateNightlyReportAsync(tenant, token));
Senior signal: You reach for a concurrency limit unprompted and can explain why unbounded WhenAll is an outage (pool/socket/downstream exhaustion).
7. How should a CancellationToken flow through your code?
Short answer: Accept it as the last parameter, pass it to every async call you make, and check ThrowIfCancellationRequested() before expensive work. A token you accept but never pass on is a bug.
Deep dive: Cancellation is cooperative — it only works if every layer honors it. In Mattrx an aborted dashboard request should stop the Azure SQL query, not keep burning DB CPU.
public async Task<CampaignKpis> AggregateAsync(TenantId t, Guid c, DateRange r, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
await using var conn = await _factory.OpenAsync(ct); // ct passed
await using var cmd = conn.CreateCommand();
// ...
return await cmd.ExecuteReaderAsync(ct) is var reader ...; // ct passed
}
Senior signal: You treat "swallowing the token" as a real bug and know cancellation is cooperative, not preemptive.
8. When would you return IAsyncEnumerable<T> instead of Task<List<T>>?
Short answer: When you want to stream results and process them as they arrive without buffering the whole set in memory — e.g., paging millions of rows.
Deep dive: Returning Task<List<T>> for a huge result set materializes everything at once. IAsyncEnumerable<T> with await foreach yields lazily, keeping the working set small.
// Streams events from Azure SQL without buffering 180M rows.
public async IAsyncEnumerable<CampaignEvent> StreamEventsAsync(
TenantId tenant, [EnumeratorCancellation] CancellationToken ct)
{
await using var reader = await OpenReaderAsync(tenant, ct);
while (await reader.ReadAsync(ct))
yield return Map(reader); // one row at a time, backpressure-friendly
}
Senior signal: You mention [EnumeratorCancellation] and framing it as memory/backpressure, not just "it's the async version of yield."
Block B — Memory, GC & allocations
9. struct vs class — when do you reach for a struct, and what's the trap?
Short answer: Use a small, immutable struct (readonly, ≤16 bytes-ish) for value-semantic data on hot paths to avoid heap allocation. The trap is mutable structs and unintended copies — a struct is copied on assignment, passing, and when boxed.
Deep dive: Mattrx uses a readonly record struct for money and IDs that flow through millions of events; each avoids a heap object. But a mutable struct in a collection leads to "I changed it and nothing happened" bugs.
public readonly record struct Money(long AmountMinor, string Currency); // no heap alloc, value equality
// TRAP: mutating a struct retrieved from a list mutates a COPY, not the element.
var list = new List<MutablePoint> { new() { X = 1 } };
list[0].X = 5; // compiler error for List indexer, but via a local it silently no-ops
Senior signal: You say "prefer readonly struct, beware copies and boxing," not just "structs are on the stack" (which isn't even always true).
10. Where does boxing silently happen, and why care?
Short answer: Boxing wraps a value type in a heap object whenever it's treated as object or a non-generic interface — in object collections, string formatting of value types in some paths, and struct implementing an interface accessed via the interface. It's a hidden allocation on hot paths.
Deep dive: At Mattrx's ingestion rate, a boxing allocation per event is millions of gen-0 objects/sec.
// BOXING: EventType (an enum) boxed to object on every add.
var tags = new Dictionary<string, object> { ["type"] = EventType.Click };
// NO BOXING: keep it strongly typed / use generics.
var tags = new Dictionary<string, EventType> { ["type"] = EventType.Click };
Senior signal: You can point to specific boxing sites (interfaces on structs, object APIs) and tie them to GC pressure, not just recite the definition.
11. What problem do Span<T> and Memory<T> solve?
Short answer: They let you slice and process contiguous memory (arrays, stack buffers, strings) without allocating or copying. Span<T> is stack-only (can't be a field or awaited across); Memory<T> is the heap-friendly version for async.
Deep dive: Parsing Mattrx's ingestion payloads with Span<char>/ReadOnlySpan<byte> avoids substring allocations per field.
// Parse "campaignId:eventType:amount" with zero substring allocations.
static (ReadOnlySpan<char> id, ReadOnlySpan<char> type) SplitHeader(ReadOnlySpan<char> line)
{
var first = line.IndexOf(':');
var second = line[(first + 1)..].IndexOf(':') + first + 1;
return (line[..first], line[(first + 1)..second]);
}
Senior signal: You know Span<T> can't cross an await (it's a ref struct) and reach for Memory<T> in async code.
12. How does the GC work, and why do gen-2 collections hurt?
Short answer: .NET uses a generational, mark-and-sweep GC. Most objects die young and are collected cheaply in gen 0; survivors are promoted to gen 1, then gen 2. Gen-2 collections scan the whole heap and are expensive; objects >85KB go on the Large Object Heap and are effectively gen 2.
Deep dive: The Mattrx win of going from 2.1GB to 380MB working set was largely about not promoting short-lived allocations to gen 2 — fewer allocations, less promotion, cheaper GC.
Gen 0 (small, frequent, cheap) ─survive─▶ Gen 1 ─survive─▶ Gen 2 (whole-heap, expensive)
Objects > 85 KB ─────────────────────────────────────────▶ LOH (collected with gen 2)
Senior signal: You connect allocation patterns to promotion and gen-2/LOH cost, and you mention pooling (ArrayPool<T>) for large buffers.
13. Explain IDisposable and the using statement. When do you implement the full dispose pattern?
Short answer: IDisposable gives deterministic cleanup of unmanaged/expensive resources; using guarantees Dispose() runs even on exception. You implement the full pattern (with a finalizer) only when you directly own unmanaged resources; if you only hold other IDisposables, a simple Dispose is enough.
Deep dive: Most Mattrx types just chain disposal of a SqlConnection/HttpClient; no finalizer needed. Finalizers are a last-resort safety net and hurt GC (they promote objects).
public sealed class TenantDbSession(SqlConnection connection) : IAsyncDisposable
{
public ValueTask DisposeAsync() => connection.DisposeAsync(); // just chain; no finalizer
}
Senior signal: You say "finalizer only for unmanaged resources, and it costs a GC generation," and you prefer IAsyncDisposable for async cleanup.
14. Why is wrapping HttpClient in a using a production bug?
Short answer: Disposing HttpClient per call leaves sockets in TIME_WAIT and exhausts ports under load; but a single static HttpClient never picks up DNS changes. The right answer is IHttpClientFactory.
Deep dive: This is the classic senior gotcha. Mattrx calls Stripe and internal services via named clients from the factory, which pools handlers and rotates them.
// BUG: socket exhaustion under load.
using var http = new HttpClient();
// CORRECT: pooled, DNS-aware handlers.
public sealed class StripeClient(IHttpClientFactory factory)
{
public Task<HttpResponseMessage> ChargeAsync(...) =>
factory.CreateClient("stripe").PostAsync(...);
}
Senior signal: You know both failure modes (socket exhaustion vs stale DNS) and name IHttpClientFactory as the resolution.
15. record vs class — what do you actually get?
Short answer: A record gives value-based equality, a ToString(), and non-destructive mutation via with, and is ideal for immutable DTOs/messages. A class has reference equality and is for entities with identity and mutable behavior.
Deep dive: Mattrx's queue messages and query results are records (immutable, value-compared, easy to copy-with-changes); domain aggregates with behavior are classes.
public sealed record WebhookDelivery(Guid EventId, string TenantId, Uri Endpoint, string Body);
var retry = delivery with { Endpoint = fallbackEndpoint }; // new instance, original untouched
Senior signal: You mention value equality, immutability, and with, and you don't make an entity-with-identity a record (value equality would be wrong).
16. How do you keep string handling from allocating?
Short answer: Strings are immutable, so concatenation in loops allocates repeatedly — use StringBuilder for building, interpolation for readability on cold paths, and string.Create/spans on hot paths.
Deep dive: Building cache keys or log lines in Mattrx's per-event path uses interpolation sparingly and avoids + in loops.
// BAD: O(n^2) allocations building a key list.
var s = ""; foreach (var id in ids) s += id + ",";
// GOOD:
var sb = new StringBuilder();
foreach (var id in ids) sb.Append(id).Append(',');
Senior signal: You reason about allocation count, not just correctness, and know interpolated strings still allocate.
Block C — the type system & language semantics
17. Explain the captured-variable closure bug.
Short answer: A closure captures the variable, not its value. In older loop patterns, all closures shared one loop variable and saw its final value. Modern C# fixes foreach, but the trap still bites with a shared mutable captured variable.
Deep dive: This shows up in Mattrx when scheduling per-campaign tasks in a loop.
// TRAP (classic for-loop): every task captures the SAME i.
for (int i = 0; i < campaigns.Count; i++)
tasks.Add(Task.Run(() => Process(campaigns[i]))); // may all see i == Count
// FIX: capture a fresh local.
for (int i = 0; i < campaigns.Count; i++)
{
var c = campaigns[i];
tasks.Add(Task.Run(() => Process(c)));
}
Senior signal: You say "closures capture the variable, not the value," and know foreach was fixed in C# 5 but shared locals still bite.
18. ref, out, in — what do they mean and when do you use them?
Short answer: ref passes by reference (read/write), out is write-only (must be assigned before return), in passes by readonly reference (avoids copying a large struct without allowing mutation).
Deep dive: in matters for large readonly structs on hot paths — it avoids the copy that by-value passing makes.
// Pass a large readonly struct by reference to avoid copying it per call.
static decimal Total(in LargeAggregate a) => a.Impressions + a.Clicks; // no copy
// out is the Try-pattern:
if (_cache.TryGet(key, out CampaignKpis kpis)) return kpis;
Senior signal: You mention in for large structs (defensive copies!) and know out powers the Try pattern.
19. What are covariance and contravariance in generics?
Short answer: Covariance (out T) lets you use a more-derived type where a less-derived is expected (IEnumerable<string> → IEnumerable<object>); contravariance (in T) is the reverse for inputs (Action<object> → Action<string>). Only interfaces/delegates, only reference types.
Deep dive: It's why you can return IReadOnlyList<Derived> as IReadOnlyList<Base>.
IReadOnlyList<PremiumCampaign> premium = GetPremium();
IReadOnlyList<Campaign> all = premium; // OK: IReadOnlyList<out T> is covariant
Senior signal: You tie out/in to output/input positions and note it doesn't work with value types.
20. What do nullable reference types actually guarantee?
Short answer: Nothing at runtime — they're compile-time hints (string? vs string) that surface likely nulls as warnings. They don't prevent a null arriving from an unannotated library, reflection, or deserialization.
Deep dive: Mattrx enables NRTs and treats warnings as errors, but still validates inputs at trust boundaries because the guarantee is static, not enforced.
#nullable enable
public CampaignKpis Get(string tenantId) // compiler assumes non-null…
{
ArgumentException.ThrowIfNullOrEmpty(tenantId); // …but validate at the boundary anyway
...
}
Senior signal: You say "compile-time only, not a runtime guarantee" and still validate at boundaries.
21. Explain deferred execution and the multiple-enumeration trap in IEnumerable<T>.
Short answer: A LINQ query over IEnumerable<T> doesn't run until you enumerate it; enumerating it twice runs it twice — re-querying the source (or re-reading a stream). Materialize with ToList() when you'll enumerate more than once.
Deep dive: This silently doubled a DB round-trip in Mattrx until it was caught.
// BUG: query executed TWICE — Count() enumerates, then the foreach enumerates again.
IEnumerable<CampaignEvent> q = repo.Query(tenant); // deferred
if (q.Count() > 0) // hits the DB
foreach (var e in q) Handle(e); // hits the DB AGAIN
// FIX: materialize once.
var events = repo.Query(tenant).ToList();
Senior signal: You spot the double enumeration and know IEnumerable can be a live query, not a buffered list.
22. == vs .Equals() vs ReferenceEquals — and the GetHashCode contract.
Short answer: == is a static operator (can be overloaded; default is reference equality for classes, value for structs); .Equals() is virtual value equality; ReferenceEquals is always identity. If you override Equals, you must override GetHashCode consistently.
Deep dive: A type used as a dictionary key with a broken GetHashCode "loses" entries.
// Using a record gives correct, consistent Equals + GetHashCode for free:
public readonly record struct TenantId(Guid Value); // safe as a dictionary key
Senior signal: You state the invariant "equal objects must have equal hash codes" and reach for record/record struct to get it right automatically.
23. Show a senior use of pattern matching.
Short answer: Switch expressions + property/type patterns replace nested if/is/cast ladders with exhaustive, readable branching — great for mapping states and message types.
Deep dive: Mattrx routes webhook events by type and status with a switch expression.
var action = evt switch
{
{ Type: "payment_intent.succeeded" } => DeliveryAction.PostLedger,
{ Type: "charge.refunded", Amount: > 0 } => DeliveryAction.Reverse,
{ Type: var t } when t.StartsWith("customer.") => DeliveryAction.SyncCustomer,
_ => DeliveryAction.Ignore,
};
Senior signal: You use property patterns, when guards, and rely on exhaustiveness rather than a wall of ifs.
24. How do extension methods resolve, and what's the pitfall?
Short answer: They're static methods the compiler rewrites into static calls based on the static type and using-imported namespaces — so this can be null, they can't be overridden, and instance methods always win over extensions.
Deep dive: An extension "override" that isn't picked up is usually a namespace/using issue or an instance method shadowing it.
public static class KpiExtensions
{
// 'this' can be null — extensions are just static calls.
public static bool IsEmpty(this CampaignKpis? k) => k is null || k.Impressions == 0;
}
Senior signal: You know resolution is compile-time on the static type, this may be null, and instance methods take precedence.
Block D — LINQ, collections & production patterns
25. IQueryable<T> vs IEnumerable<T> — the EF Core trap.
Short answer: IQueryable<T> builds an expression tree that EF translates to SQL (filtering runs in the database); IEnumerable<T> runs LINQ in memory. Accidentally switching to IEnumerable (e.g., calling a C# method EF can't translate) pulls the whole table into memory first.
Deep dive: This is the difference between a WHERE clause and loading 1.2B rows.
// GOOD: filter runs in Azure SQL (IQueryable).
var q = _db.CampaignEvents.Where(e => e.TenantId == tenant && e.OccurredAt >= from);
// TRAP: ToList() first => the Where now runs in memory over the ENTIRE table.
var bad = _db.CampaignEvents.ToList().Where(e => e.TenantId == tenant);
// TRAP: a non-translatable method forces client evaluation.
var alsoBad = _db.CampaignEvents.Where(e => MyHelper(e.Payload)); // can't become SQL
Senior signal: You watch for the IQueryable→IEnumerable boundary and know client evaluation is where performance dies.
26. Thread-safe state: lock, Interlocked, or a concurrent collection?
Short answer: Use Interlocked for single-variable atomic ops (counters), ConcurrentDictionary/Channel for shared collections, and lock only for a genuine multi-step critical section. Prefer lock-free primitives on hot paths.
Deep dive: Mattrx's in-process metrics counter uses Interlocked, not a lock, at ingestion rate.
// Atomic, lock-free counter on the hot path.
Interlocked.Increment(ref _eventsProcessed);
// Multi-step invariant that truly needs a critical section:
lock (_sync) { _buffer.Add(item); if (_buffer.Count >= BatchSize) Flush(); }
Senior signal: You pick the cheapest correct primitive and don't wrap a single ++ in a lock.
27. DI service lifetimes — what's the captive dependency bug?
Short answer: Singleton, Scoped, Transient. The captive dependency bug is a singleton that captures a scoped/transient service, extending its lifetime forever — e.g., a singleton holding a scoped DbContext, which then leaks state and breaks thread-safety.
Deep dive: ASP.NET Core's scope validator catches many of these in dev; seniors avoid them by design.
// BUG: singleton captures a scoped DbContext (lives for the whole app, not the request).
services.AddSingleton<CacheWarmer>(); // and CacheWarmer ctor takes MattrxDbContext (scoped)
// FIX: resolve the scoped dependency per use via a scope factory.
public sealed class CacheWarmer(IServiceScopeFactory scopes)
{
public async Task WarmAsync() { using var s = scopes.CreateScope(); var db = s.ServiceProvider.GetRequiredService<MattrxDbContext>(); ... }
}
Senior signal: You name "captive dependency," know DbContext is scoped and not thread-safe, and use IServiceScopeFactory from singletons.
28. What does mature exception handling look like?
Short answer: Catch the specific exceptions you can act on, use exception filters (when) instead of catch-and-rethrow, never swallow silently, and preserve stack traces (throw;, not throw ex;).
Deep dive: Mattrx retries only transient SQL errors and lets the rest bubble to the middleware that logs + maps to a problem-details response.
try { await _repo.SaveAsync(entity, ct); }
catch (SqlException ex) when (ex.IsTransient()) { await RetryAsync(entity, ct); } // act only on transient
// don't catch Exception here just to log and rethrow — let the middleware do it once.
// preserve the stack:
catch (Exception) { Cleanup(); throw; } // NOT: throw ex;
Senior signal: You use when filters, throw; to preserve traces, and centralize logging in middleware rather than catching everywhere.
29. ConcurrentDictionary.GetOrAdd — what's the subtle gotcha?
Short answer: The value factory is not run under a lock, so under contention it can execute more than once for the same key (only one result is kept). If the factory is expensive or has side effects, that's a bug.
Deep dive: Mattrx caches compiled per-tenant templates; running the (expensive) compile twice wastes CPU, so it wraps the value in a Lazy<T>.
// GOTCHA: BuildTemplate() may run twice for the same tenant under load.
_cache.GetOrAdd(tenantId, id => BuildTemplate(id));
// FIX: Lazy<T> makes the factory run exactly once, even if GetOrAdd races.
_cache.GetOrAdd(tenantId, id => new Lazy<Template>(() => BuildTemplate(id))).Value;
Senior signal: You know the factory isn't atomic and reach for Lazy<T> when the factory is costly or side-effecting.
30. Why is returning IEnumerable<T> from a repository method risky?
Short answer: If the IEnumerable is a lazy query (yield/EF), the caller controls when and how many times it executes — after your using DbContext is disposed, or twice. Return a materialized IReadOnlyList<T> (or make streaming explicit with IAsyncEnumerable).
Deep dive: A repo that returns a deferred IEnumerable and disposes its connection has already lost — the caller enumerates a dead query.
// RISKY: caller might enumerate after the DbContext scope is gone, or twice.
public IEnumerable<Campaign> GetActive() => _db.Campaigns.Where(c => c.IsActive);
// SAFE: materialize before returning ownership.
public async Task<IReadOnlyList<Campaign>> GetActiveAsync(CancellationToken ct)
=> await _db.Campaigns.Where(c => c.IsActive).ToListAsync(ct);
Senior signal: You think about who owns execution and lifetime, and return materialized read-only collections from boundaries.
Bonus — the meta-question: what makes an answer "senior"?
Short answer: A junior answers what; a senior answers why, when-not-to, and what it costs. The same question ("how does async work?") gets "it waits for the task" from a junior and "it suspends, frees the thread, and the continuation is scheduled — and here's how blocking on it deadlocks" from a senior.
Deep dive: Across all 30 answers the pattern repeats: name the mechanism, tie it to a real failure mode (allocation, deadlock, exhaustion, double-execution), and state the trade-off. Interviewers aren't checking whether you memorized the language spec — they're checking whether you've been on call for it.
Senior signal: You reach for measurement ("I'd profile before assuming"), you volunteer the failure mode before you're asked, and you can say "here's when I would not do this."
The model to carry forward
Senior C# is less about exotic language features and more about a handful of production truths: async suspends, it doesn't block; allocations become GC pressure; the type system is a compile-time aid, not a runtime guarantee; and the boundary between in-memory and in-database (or one thread and many) is where systems break. Answer every question by naming the mechanism, the real failure it prevents, and the trade-off — and you'll sound like someone who has shipped and supported the code, which is exactly the signal.
Further reading
- React Interview Questions for Senior Developers — Hooks, Reconciliation, Fiber & Performance (Deep Answers)
- Race Conditions & Concurrency Bugs in ASP.NET Core Production
- Scaling ASP.NET Core APIs to 100,000 Requests per Minute
Prepping for a senior .NET interview and want these drilled live, or a mock focused on your weak block? I'm always happy to help — reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.