A .NET developer interviewing for a mid-level role at a Chicago insurance company last winter got a live-coding prompt most candidates don't expect anymore. Fix a deadlocking ASP.NET Core endpoint, on a shared screen, with the interviewer watching every keystroke. She'd read a dozen blog posts on async in C#. She'd never actually reproduced a deadlock by hand. She froze for four minutes before finding a blocking .Result call buried three layers deep in a repository method.
That kind of question, not "what is dependency injection" but "here's a bug, find it live", is where .NET interview questions have moved by 2026. C# sits at 27.1% usage among all developers in the 2025 Stack Overflow Developer Survey, comfortably inside the top ten languages, most of it clustered around ASP.NET Core, EF Core, and Azure work. Syntax questions are searchable in ten seconds, so interviewers ask about the CLR, the garbage collector, and async internals instead, the kind of thing you only get right once you've actually debugged it.
My honest opinion: most .NET interview prep lists spend too long on trivia like var versus dynamic and not nearly enough time on garbage collector generations or why async void quietly breaks your error handling. Those are the questions that separate a 2-year candidate from a 6-year one. This page skips the trivia and works through the eight areas that keep showing up in real 2026 loops, with two coding exercises folded in where they fit naturally.
C# Fundamentals and Object-Oriented Programming
Every .NET loop still opens with a warm-up on language basics. Nobody fails an interview here, but a shaky answer sets a bad tone for the 45 minutes after it.
Easy questions
15Encapsulation means hiding internal state behind private fields and exposing it through properties or methods. Abstraction means exposing only what a consumer needs, usually through an interface or abstract class. Inheritance lets a class extend another with the : BaseClass syntax, single inheritance only. Polymorphism shows up two ways: compile-time (method overloading) and runtime (virtual methods overridden in a derived class, resolved through the vtable at the moment of the call).
Interviewers rarely stop at the definitions. Expect a quick follow-up like "write a small example" on a shared screen, and a question about why C# doesn't allow multiple class inheritance, the diamond problem, and how interfaces sidestep it.
Value types (struct, int, bool, enum, DateTime) hold their data directly, so assigning one copies the entire value. Reference types (class, arrays, delegates) hold a pointer to data on the heap, so assigning one copies the pointer, not the object itself, meaning both variables end up pointing at the same data.
Interviewers often ask about string specifically, since it's technically a reference type but behaves like a value type in practice because it's immutable. Every "modification" actually creates a new string object, which is exactly why repeated concatenation in a loop should use StringBuilder instead.
public struct PointStruct
{
public int X;
public int Y;
}
public class PointClass
{
public int X;
public int Y;
}
PointStruct s1 = new PointStruct { X = 1, Y = 2 };
PointStruct s2 = s1; // copies the values
s2.X = 99;
Console.WriteLine(s1.X); // still 1
PointClass c1 = new PointClass { X = 1, Y = 2 };
PointClass c2 = c1; // copies the reference
c2.X = 99;
Console.WriteLine(c1.X); // 99, both variables point at the same objectList<T> keeps insertion order and allows duplicates, but a lookup by value is O(n). Dictionary<TKey,TValue> gives O(1) average lookup by key through hashing. HashSet<T> gives O(1) average membership checks and rejects duplicates, with no guaranteed ordering.
A fair follow-up: "how would you find duplicates in a list of a million items efficiently?" The answer is a HashSet<T> to track what you've already seen, one pass, O(n) total, instead of a nested loop.
Select projects each element into a new form, one in, one out. SelectMany flattens a sequence of sequences into a single flat sequence, one in, zero or more out per element.
The usual example: a list of orders, each with a list of items. orders.Select(o => o.Items) gives you a list of lists. orders.SelectMany(o => o.Items) gives you a single flat list of every item across every order.
finally runs even if the try or catch block returns, and even if an exception propagates out unhandled. It does not run if the process itself dies abruptly, a StackOverflowException, a call to Environment.FailFast, the process getting killed externally, or the machine losing power.
Candidates who answer "always" without that caveat usually haven't thought about the difference between an exception unwinding the stack (finally runs) and the process itself terminating (finally doesn't get the chance).
Subclass Exception (or a more specific existing type), match the standard constructor overloads, and add whatever custom context the caller actually needs, like an account ID or an error code.
It's worth doing when calling code needs to programmatically distinguish and handle a specific failure mode, catching InsufficientCreditException to trigger a specific retry path, for instance. It's not worth doing just because a generic Exception with a custom message "feels" less professional.
public class InsufficientCreditException : Exception
{
public int AccountId { get; }
public InsufficientCreditException(int accountId, string message)
: base(message)
{
AccountId = accountId;
}
}Running dotnet ef migrations add generates a C# migration class with Up() and Down() methods, plus an updated snapshot of your current model shape. Applying it with dotnet ef database update executes the Up() method's SQL, and EF Core records which migrations have run in a __EFMigrationsHistory table inside the target database itself.
A decent follow-up: two developers create migrations on separate branches at the same time, what happens? A merge conflict in the model snapshot, usually solved by regenerating one migration after merging rather than hand-editing the conflict away.
Expect OOP basics (encapsulation, inheritance, polymorphism), value vs. reference types, a couple of collection questions (List vs. Dictionary), basic exception handling, and simple LINQ queries. System design and deep CLR internals mostly show up at mid-level and above, not for freshers.
Almost entirely modern .NET now (.NET 6 through .NET 9 and beyond), especially for anything greenfield. Legacy .NET Framework knowledge still matters for roles maintaining older enterprise systems, banking and insurance backends show up here often, so it's worth asking the recruiter which stack the team actually runs before you prep.
C# alone gets you through the language-fundamentals portion, not the whole loop. Most .NET developer roles expect at least working familiarity with ASP.NET Core (controllers or minimal APIs, middleware, DI) and EF Core (migrations, LINQ queries, the N+1 problem), since that's what the actual job looks like day to day.
Usually 3 to 4: a recruiter screen, one or two technical rounds, and a final round with the hiring manager. Smaller companies compress this to 2; larger enterprises stretch it to 5.
const is a compile-time constant. The value gets baked directly into the IL at every call site, so if you change a const value in a referenced assembly and don't recompile the code that consumes it, that code keeps using the old baked-in value until it's rebuilt. const also only works with primitive types, strings, and enums, since the compiler needs to know the value while it's compiling.
readonly is a runtime constant. It can only be assigned in the field initializer or inside a constructor of that same class, but the value itself is computed at runtime, so it can hold the result of a method call, a database lookup, or anything else that isn't known ahead of time. Two instances of the same class can also end up with different readonly values if it's an instance field.
static readonly is readonly scoped to the type instead of the object, one shared value across every instance, still assigned at runtime, often in a static constructor, without the versioning trap that const carries. If a value might ever need to change without forcing every consumer to recompile, static readonly is the safer default over const in production code.
For reference types, == by default and ReferenceEquals do the same thing unless a class overloads the == operator: they check whether two variables point at the same object in memory. .Equals() is virtual and can be overridden, so a class can define what counts as equal for its own data, like two Point objects with matching X and Y values being equal even though they're two separate objects on the heap.
string is the type that trips people up. string overloads == to do a value comparison instead of a reference comparison, so two separately built strings with the same characters compare equal with ==, thanks to that overload plus string interning caching identical literals. But build a string at runtime, say with StringBuilder, and ReferenceEquals against a matching literal comes back false even though == is true, because interning only applies to values known at compile time.
For your own classes, overriding Equals() without also overriding GetHashCode() consistently is a real bug generator, the object behaves fine on its own but breaks the moment it goes into a Dictionary or a HashSet, since those rely on GetHashCode() to even find the right bucket before Equals() ever gets called.
An array has a fixed size decided at creation. Once you allocate one, that block can't grow, if you need more room you allocate a brand-new array and copy everything across yourself. Arrays are stored as one contiguous block, which is why they're fast for tight numeric loops and interop with unmanaged code.
List
One gotcha worth knowing: object[] arr = new string[3] compiles and runs fine right up until you try arr[0] = 5, which throws an ArrayTypeMismatchException at runtime. List
int is a value type, it can never be null on its own, it always has a default like 0. int? (shorthand for Nullable
You read it with .Value, which throws an InvalidOperationException if HasValue is false, or with .GetValueOrDefault(), and the null-coalescing operator (someNullableInt ?? 0) is the idiomatic way to unwrap it safely in one line. It shows up constantly with database columns that allow NULL, since a plain int can't represent that but int? maps cleanly onto a nullable SQL column through EF Core.
Medium questions
25An abstract class can hold shared state, constructor logic, and partial implementation, but a class can only inherit from one. An interface is a pure contract (no state until default interface methods arrived in C# 8, and even those are meant for narrow backward-compatibility cases), but a class can implement as many interfaces as it needs.
The follow-up interviewers like: "why not just always use interfaces, then?" The honest answer is that abstract classes save you from repeating constructor logic and shared fields across every implementation, which interfaces still can't do cleanly even with default methods.
Boxing wraps a value type in a heap-allocated object so it can be treated as a reference type (assigned to an object variable, stored in a non-generic collection, and so on). Unboxing extracts the value back out, with a runtime type check. Every boxing operation is a real heap allocation with object-header overhead, not a free cast.
In a loop that runs a few million times, boxed values mean a few million extra heap allocations, which turns into extra Gen0 garbage collections and measurable slowdown. Generic collections like List<int> avoid this entirely because the int is stored inline, not boxed.
// Boxing: each Add allocates a new object on the heap
ArrayList boxedNumbers = new ArrayList();
for (int i = 0; i < 1_000_000; i++)
{
boxedNumbers.Add(i); // int gets boxed into an object here
}
// No boxing: List<int> stores the ints inline, no per-item allocation
List<int> plainNumbers = new List<int>();
for (int i = 0; i < 1_000_000; i++)
{
plainNumbers.Add(i);
}No, and this trips up a lot of candidates who memorized "value types live on the stack" without the caveat. A value type declared as a local variable inside a method does live on the stack. But a value type that's a field of a class instance lives on the heap, as part of that object's memory block, because the whole object got heap-allocated. Boxed value types also end up on the heap.
Interviewers sometimes phrase this as "where does this struct live" while pointing at a struct property on a class. If your answer is "the stack, always", that's the wrong answer, and it's a common enough mistake that a few interviewers ask this specifically to filter for it.
Because System.String is immutable in C#, you can't mutate its characters directly, there's no true in-place reversal of a string instance. Convert it to a char[], swap characters from both ends toward the middle with two pointers, then build a new string from the array.
Working code alone doesn't close this one out. Interviewers want to hear you say the immutability part out loud, and a candidate who writes the two-pointer swap but can't explain why you needed a char array in the first place hasn't really shown they understand the string model.
public static string ReverseString(string input)
{
char[] chars = input.ToCharArray();
int left = 0;
int right = chars.Length - 1;
while (left < right)
{
char temp = chars[left];
chars[left] = chars[right];
chars[right] = temp;
left++;
right--;
}
return new string(chars);
}The GC splits the heap into Gen0, Gen1, and Gen2. New objects start in Gen0, which gets collected most often and fastest. Anything that survives a Gen0 collection gets promoted to Gen1, and anything that survives long enough gets promoted to Gen2. The idea, confirmed in Microsoft's own documentation, is the generational hypothesis: in a well-behaved app, most objects die young, so it's cheaper to sweep the young generation constantly than to scan the whole heap every time.
The follow-up that separates candidates: objects 85,000 bytes or larger skip straight to the Large Object Heap, which is logically part of Gen2 and collected far less often. That means a stream of large temporary arrays can fragment memory badly even though nothing is technically "leaking".
IDisposable.Dispose() lets you release unmanaged resources deterministically, file handles, database connections, native memory, instead of waiting for the GC to eventually get around to it. You only need a finalizer if your class directly owns an unmanaged resource. If you only hold other IDisposable objects (a SqlConnection, a FileStream), you don't need one, just call their Dispose() from yours.
The standard dispose pattern below is a fair thing to ask a candidate to write from memory. Watch for whether they remember GC.SuppressFinalize(this), plenty of candidates get the if (disposing) branch right and forget that line entirely.
public class FileWrapper : IDisposable
{
private FileStream _stream;
private bool _disposed;
public FileWrapper(string path)
{
_stream = new FileStream(path, FileMode.OpenOrCreate);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_stream?.Dispose(); // release managed resources
}
_disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}The modern, idiomatic approach is Lazy<T>, which the CLR guarantees is thread-safe by default (LazyThreadSafetyMode.ExecutionAndPublication). Nobody should be hand-rolling locking code for a simple singleton in 2026.
The classic double-checked locking pattern still gets asked because it tests memory visibility across threads, a deeper thing than locking syntax on its own. Without the volatile keyword on the backing field, a thread could observe a partially constructed object due to instruction reordering, a subtle bug that only shows up under real concurrency, never in a single-threaded test run.
public sealed class ConfigManager
{
private static readonly Lazy<ConfigManager> _instance =
new Lazy<ConfigManager>(() => new ConfigManager());
public static ConfigManager Instance => _instance.Value;
private ConfigManager()
{
// load configuration once, on first access
}
}
// Shown for context only, this is legacy pre-Lazy<T> knowledge:
public sealed class LegacySingleton
{
private static volatile LegacySingleton _instance;
private static readonly object _lock = new object();
public static LegacySingleton Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = new LegacySingleton();
}
}
}
return _instance;
}
}
private LegacySingleton() { }
}IQueryable<T> builds an expression tree that gets translated into a query, SQL in EF Core's case, and executed at the data source, only the matching rows come back. IEnumerable<T> executes in memory, LINQ to Objects, against whatever's already been loaded.
The bug candidates walk into constantly: calling .AsEnumerable() or casting to IEnumerable before filtering forces every row in the table into memory first, then filters client-side. On a table with a few hundred thousand rows, that's the difference between a query that returns in 40 milliseconds and one that takes 12 seconds and pins a chunk of RAM doing it.
// IQueryable: filter is translated to SQL and runs in the database
IQueryable<Order> query = dbContext.Orders
.Where(o => o.Total > 500);
List<Order> bigOrders = query.ToList(); // only matching rows cross the wire
// The bug: forcing IEnumerable first pulls every row into memory,
// then filters in .NET
IEnumerable<Order> allOrders = dbContext.Orders.AsEnumerable();
List<Order> bigOrdersSlow = allOrders.Where(o => o.Total > 500).ToList();A LINQ query isn't actually executed when you write it, only when you enumerate it: a foreach, a .ToList(), a .Count(). The query variable just holds a description of the work.
Two bugs follow directly from this. First, modifying the underlying collection between defining the query and enumerating it throws InvalidOperationException ("collection was modified"). Second, enumerating the same query twice re-runs the whole pipeline both times, which means two database round trips for an IQueryable, not one cached result.
The compiler rewrites your async method into a state machine. When execution hits an await on a task that isn't finished yet, the method returns control to its caller right away, it doesn't block the calling thread. The rest of the method becomes a continuation that resumes once the awaited task completes, either back on the original synchronization context or on a thread pool thread, depending on the environment.
Interviewers who ask this want to see that you know async/await isn't magic multithreading, it's a compiler transform for non-blocking continuation, and the actual thread that resumes the method may not be the thread that started it.
async Task gives the caller something to await, which means exceptions thrown inside the method propagate normally and can be caught. async void is fire-and-forget: the caller has no way to await it, no way to know when it finishes, and an exception thrown inside gets rethrown directly on the synchronization context, often crashing the process instead of surfacing where you'd expect.
The one legitimate use of async void is a top-level UI event handler, where the framework's delegate signature is fixed and you have no choice. Anywhere else, it's a smell worth asking about.
Specific, almost always. Catch SqlException, HttpRequestException, whatever the actual failure mode is, and let anything you didn't anticipate propagate up. Catching the base Exception class swallows bugs you never intended to catch, a NullReferenceException from a real defect looks identical to an expected failure once it's inside a broad catch block.
My honest take: I'd rather see an unhandled exception crash a dev environment loudly than have a silent catch (Exception) hide a real bug in production for six months. Interviewers who ask this are usually checking whether you treat broad catches as a liability instead of a safety net.
Middleware is a chain of components registered with app.Use(...), each one acting on the request going in, calling the next delegate, then acting on the response coming back out. Order matters because each middleware only sees what runs before it: authentication has to run before authorization, and exception handling usually goes first so it can catch failures from everything downstream.
A reasonable follow-up: "what happens if you register UseAuthorization before UseAuthentication?" Requests get rejected for lacking authorization even when they'd have authenticated fine, because authorization ran against a request that hadn't been authenticated yet.
var app = builder.Build();
app.UseExceptionHandler("/error"); // catch unhandled exceptions first
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication(); // must run before UseAuthorization
app.UseAuthorization();
app.MapControllers();
app.Run();ASP.NET Core merged the old separate MVC and Web API frameworks into one controller base years ago, so today's real distinction is Controller-based (attribute routing, model binding, filters, either serving views or JSON) versus Minimal APIs, introduced in .NET 6, which map endpoints directly without a controller class at all.
I'll take a stance here: for anything that's a pure JSON service with maybe a dozen endpoints or fewer, I default to Minimal APIs now, less ceremony, faster to read. Past that size, controllers with filters and model binding start earning their keep again, and I'd rather not manage forty loose endpoint delegates in one file.
The DbContext tracks the state of every entity it's aware of, Added, Modified, Deleted, or Unchanged. When SaveChanges() runs, it walks the tracked entities, compares current values to the original snapshot, and generates the corresponding INSERT, UPDATE, or DELETE statements.
Interviewers often ask about .AsNoTracking() next: for read-only queries where you'll never call SaveChanges() on the results, turning off tracking skips the snapshot bookkeeping entirely and measurably reduces overhead, sometimes 20 to 30 percent on large result sets in real workloads I've seen reported.
Prepare both. EF Core generates SQL for you, but debugging a slow query, reading an execution plan, or explaining why a LINQ query produced a bad JOIN all require actual SQL knowledge underneath. Interviewers at anything beyond junior level will ask you to reason about the generated SQL itself, and the LINQ syntax on top of it won't cover for you there.
record was added in C# 9 to make writing immutable data-carrying types far less painful. A class needs you to hand-write Equals, GetHashCode, and a constructor if you want value-based comparison; a record gives you all of that for free just by declaring the shape of the data, so two instances with the same property values compare equal and hash consistently without any extra code.
public record Person(string Name, int Age);
var p1 = new Person("Ada", 30);
var p2 = new Person("Ada", 30);
Console.WriteLine(p1 == p2); // true, value-based equality
var older = p1 with { Age = p1.Age + 1 };Records default to a reference type (record class) unless you write record struct, in which case they behave as a value type. Properties on a positional record are init-only by default, settable during construction or through a with expression like the one above, but not mutable afterward, which is exactly the shape you want for DTOs and message payloads that shouldn't change once they're created.
One thing that surprises people: the default ToString() on a record prints every property value, which is genuinely useful for logging but also means you need to be careful not to log a record that happens to carry a password or token field, since that convenience will dump it straight into your logs.
All three pass a variable by reference instead of by value, meaning the method operates on the caller's actual storage location instead of a copy, but they differ in what the compiler requires and allows.
ref requires the variable to already be assigned before the call, and the method can read it and optionally reassign it. out doesn't require the variable to be assigned beforehand, it can be an uninitialized local, but the method is required to assign it before returning, that's how TryParse-style methods work. in is the newer one, added for performance on large structs, it passes by reference but the compiler blocks the method from modifying it at all, so you avoid copying a big struct on every call while still guaranteeing the caller's value can't be mutated by surprise.
bool ok = int.TryParse("42", out int result);
void Scale(in Matrix4x4 m, ref float factor)
{
factor *= 2; // allowed
// m.M11 = 1; // compiler error, m is read-only here
}The main everyday use for ref and out today is the TryParse and TryGetValue pattern, and returning a value from a method without allocating a tuple just for that. in mostly earns its keep in hot paths dealing with large readonly structs, a custom vector or matrix type, where copying 64 or more bytes on every single call actually shows up in a profiler.
A delegate is a type-safe function pointer, it holds a reference to a method, or a list of methods since delegates are multicast, with a matching signature, and you invoke it like a method call. Action, Func, and Predicate are just pre-built generic delegate types the framework gives you so you rarely need to declare a custom delegate type anymore.
An event is a delegate with restricted access. Under the hood it's still backed by a delegate field, but exposing it through the event keyword means outside code can only add or remove a handler with += and -=, they can't call it directly or reassign the whole thing with a plain =, which would wipe out every other subscriber at once. That restriction is the entire reason events exist as a separate concept from a public delegate field.
The classic bug with events is forgetting to unsubscribe. If a long-lived publisher, a static event or a singleton service, holds a += reference to a short-lived subscriber's handler, the subscriber can never be garbage collected because the publisher's invocation list still points at it. That's a very common source of memory leaks in long-running services and desktop apps, and it's easy to miss because nothing in the code you're looking at seems to be holding the reference directly.
An extension method lets you add a method to a type you don't own or can't modify, adding something like a title-case helper to string, by writing a static method in a static class where the first parameter carries the this modifier. The compiler resolves a call like value.IsNullOrBlank() into a plain static method call at compile time, it's syntactic sugar over a static method, nothing more.
public static class StringExtensions
{
public static bool IsNullOrBlank(this string s)
=> string.IsNullOrWhiteSpace(s);
}Because it's compile-time sugar, an extension method can't reach the private members of the type it extends, it only sees what's already public or internal to the same assembly. It also can never win against a real instance method, if the type already has a method matching that exact signature, the actual instance method is called every time, the extension method is silently ignored for that call.
LINQ is built entirely on this feature. Where, Select, and OrderBy are all extension methods on IEnumerable
Task is a reference type, every completed async method that returns a value, or every Task.FromResult call, allocates an object on the heap. In a hot path that runs constantly, a caching layer that usually returns a value synchronously from an in-memory dictionary and only occasionally awaits something real, those allocations add up fast.
ValueTask
Most application code should keep returning Task, it's simpler and safer to reason about. ValueTask earns its complexity in library and infrastructure code, a custom cache-aside method or a low-level stream reader, where profiling has actually shown the allocation matters.
When you await a Task, the continuation after the await tries by default to resume on the original synchronization context it started on. In classic ASP.NET and in WPF or WinForms, that context is a single dedicated thread, and if that thread happens to be blocked waiting on something synchronously, the continuation can never resume there, that's the classic async deadlock people run into.
ConfigureAwait(false) tells the awaiter not to bother capturing that context, the continuation can run on any available thread pool thread instead. In library code that has no business knowing about the caller's UI thread or request context, using it everywhere you await avoids that whole deadlock class, and it's marginally cheaper too since there's no context-capture bookkeeping involved.
In ASP.NET Core there's no synchronization context by default, so ConfigureAwait(false) isn't fixing a deadlock that can even happen there anymore. Plenty of teams still apply it consistently in shared library code anyway, because that code might get consumed by something that does have a context, and being defensive costs nothing.
Writing yield return inside a method that returns an enumerable tells the compiler to generate a whole state machine class behind the scenes. You aren't actually building a list and returning it, the compiler rewrites the method body into a class implementing the enumerator interface, where each call to move to the next item runs your code up to the next yield return and pauses exactly there, remembering local variables and the current position.
public IEnumerable<int> GetEvens(int[] data)
{
if (data == null) throw new ArgumentNullException(nameof(data));
foreach (var n in data)
if (n % 2 == 0)
yield return n;
}
// the ArgumentNullException doesn't fire when GetEvens(...) is called,
// it fires on the first MoveNext(), i.e. the first foreach iteration.That's what makes it lazy, nothing inside the method actually runs until something starts pulling values from it, typically a foreach loop or a LINQ operator further up the chain. That's also why deferred execution in LINQ works the way it does, methods like Where and Select are themselves implemented with yield return internally.
The gotcha in the code above: if the first line does argument validation, that exception doesn't throw when you call the method, it throws on the first iteration, far away from where the method was actually invoked. The usual fix is splitting the method in two, an outer non-iterator wrapper that validates eagerly and throws immediately, calling a private iterator method underneath that does the actual yielding.
Task.WhenAll is for I/O-bound work, a batch of independent asynchronous operations, calling several different HTTP APIs or running several database queries, kicked off together and then awaited as a group. It doesn't need extra threads for the waiting itself, each task is already non-blocking while it's in flight, WhenAll just gives you back one task that completes once all of them do.
Parallel.ForEach is for CPU-bound work, splitting a synchronous, computationally heavy loop across multiple thread pool threads so it actually uses more cores at once, think image resizing or a large in-memory calculation over a big collection. Using Parallel.ForEach on network calls wastes threads sitting there blocked on I/O, and using Task.WhenAll on pure CPU-bound synchronous work doesn't help at all, the work still runs one item at a time unless each item gets explicitly pushed onto the thread pool.
The mistake I've seen most often in code review is someone wrapping a database call just to fan it out the way Parallel.ForEach would, that steals threads from the pool unnecessarily when Task.WhenAll on the natively async calls does the same job with far less pressure on the thread pool.
Optimistic concurrency means you don't lock a row while you're editing it, you check when you save whether it changed since you read it. EF Core does this with a concurrency token, commonly a rowversion column in SQL Server, mapped onto an entity property. EF Core includes that column's original value in the WHERE clause of the update statement it generates.
public class Order
{
public int Id { get; set; }
public string Status { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
}
// EF Core generates something like:
// UPDATE Orders SET Status=@p0
// WHERE Id=@id AND RowVersion=@originalRowVersionIf another process updated that row in between your read and your save, the RowVersion column in the database no longer matches what you loaded, zero rows match the WHERE clause, and EF Core throws a concurrency exception instead of silently overwriting someone else's change. You catch it, reload the current values, decide whether to merge or ask the user to redo their edit, and retry.
It's the right tool for low-contention scenarios where conflicts are rare and detecting them beats paying for row locks on every read. For a genuinely high-contention row, a shared counter getting hit many times a second, optimistic concurrency there just means a lot of failed retries, you're usually better off with an atomic database operation instead.
Hard questions
12Classic ASP.NET (pre-Core), WPF, and WinForms run under a SynchronizationContext that allows only one thread back onto it at a time. Blocking on .Result occupies that thread while it waits. The awaited task's continuation then tries to resume on that same context, but can't, since the one allowed thread is the one stuck waiting. Circular wait, deadlock.
ASP.NET Core has no such context by default, so this exact deadlock rarely happens there. Blocking async code is still bad practice, it just starves the thread pool instead. Fix in both cases: await all the way up the call stack, and use ConfigureAwait(false) in library code that doesn't need a specific context back.
Transient creates a new instance every time it's requested. Scoped creates one instance per HTTP request (or per scope). Singleton creates exactly one instance for the app's entire lifetime.
The scenario interviewers reach for now: inject a Scoped service, say a DbContext, into a Singleton. It resolves once at startup and gets held onto forever. In dev, with one request at a time, that can look fine. Under real concurrent traffic, every request shares that single non-thread-safe instance, corrupted reads or cross-request data bleed follow. This is a captive dependency, and it's exactly what a definition-only answer won't catch.
It happens when you lazy-load a related collection inside a loop over a parent list: one query loads the parent rows, then one more fires per parent for its related data, N+1 round trips instead of one. A page listing 200 orders with line items turns into 201 database calls where one would do.
The fix is eager loading with .Include() (and .ThenInclude() for nested relations), which folds the related data into the original query as a join. For read-heavy scenarios where you don't need the full entity graph, projecting directly with .Select() into a lightweight DTO avoids loading unnecessary columns entirely.
// N+1: one query for orders, then one more per order for its items
var orders = dbContext.Orders.ToList();
foreach (var order in orders)
{
var items = order.Items; // triggers a separate query, lazily
}
// Fixed: one query, items are joined in up front
var ordersWithItems = dbContext.Orders
.Include(o => o.Items)
.ToList();Covariance and contravariance describe whether a generic interface can be substituted with a more specific or more general type argument. IEnumerable
IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objects = strings; // covariant, fine
Action<object> logAny = o => Console.WriteLine(o);
Action<string> logString = logAny; // contravariant, fine
object[] items = new string[3];
items[0] = 5; // compiles, throws ArrayTypeMismatchException at runtimeAction
Arrays are the trap people bring up in interviews. Array covariance predates generics and has no such compiler safety net, which is why the code above compiles cleanly but throws at runtime the moment you actually write a mismatched value into it. The CLR has to catch at runtime something the type system let through unchecked at compile time, generic interface variance never allows that hole in the first place.
Thread pool starvation happens when every thread in the pool is busy or blocked, and new work items queue up waiting for a free one, response times climb steadily even though CPU usage looks completely normal, sometimes close to idle. The most common cause in ASP.NET Core is a blocking call inside async code, something waiting synchronously on a task inside a request handler, which ties up a thread pool thread doing nothing but waiting instead of returning it to the pool.
The pool does grow under sustained demand, but slowly, by design, roughly one new thread every couple hundred milliseconds once demand exceeds the current size, so a sudden burst of blocking calls can starve the pool for several seconds before it catches up. That's the window where the app looks like it's hung even though nothing has actually crashed.
Diagnosing it usually means grabbing a memory dump under load and comparing pending work item count against current thread count, or watching the runtime's thread pool event counters, if the queue is deep and the thread count is maxed out or barely growing, that's the signal. The fix is almost always removing the blocking call and awaiting properly all the way down, raising the minimum thread count can mask the symptom for a while but it doesn't fix the root cause.
Workstation GC is tuned for low latency on a machine that's mostly doing other things at the same time, a desktop app sharing the CPU with a UI thread, it uses a single heap and tends toward shorter, more frequent collections. Server GC is tuned for throughput on a dedicated multi-core machine, it creates one heap and one dedicated collector thread per logical core, and collections run in parallel across all of them, clearing far more garbage per pause at the cost of higher overall memory use, since each heap carries its own allocation budget.
ASP.NET Core apps almost always want Server GC turned on, it's actually the runtime default for web apps published from a recent SDK, controlled by a setting in the project file, because a web server has many cores and cares about total request throughput far more than any single collection's pause time. Getting this backwards, running Workstation GC on a sixteen-core production box, leaves most of those cores sitting idle during every collection instead of using them to clear garbage in parallel.
There's a second dimension too, concurrent (background) GC, which lets most of a full generation-two collection run on a background thread while the app keeps allocating, versus non-concurrent mode which pauses everything. Server GC running in concurrent mode is the typical production default for an API, and it's rare you'd want to turn concurrent mode off unless memory is genuinely tight, since background collection needs some extra headroom to work.
The classic leak: a long-lived object, a static service or a parent window, subscribes to an event on a shorter-lived object with += but never unsubscribes with -= when that shorter-lived object should be discarded. An event subscription is just the publisher holding a delegate reference to the subscriber's method, and a delegate pointing at an instance method keeps that instance alive, so as long as the publisher's invocation list still references it, garbage collection can never reclaim it, even though nothing else in your code appears to be holding onto it.
This shows up constantly in WPF and WinForms apps, where a view model subscribes to a static or singleton service's event and the window gets closed but never actually goes away, memory climbs slowly across the app's lifetime as each closed window quietly accumulates. It's just as real server-side, a singleton-scoped service subscribing to events on scoped or transient dependencies is a fast way to keep every request's objects alive long after the request finished.
The fix is either disciplined unsubscription, implementing IDisposable on the subscriber and unsubscribing inside Dispose, or the weak event pattern, where the publisher holds a weak reference to the subscriber instead of a strong delegate reference, so the subscriber can still be collected even if nobody ever called -=. WPF ships a weak event manager for exactly this reason, because forgetting to unsubscribe is such a common and hard-to-spot mistake.
Span
ReadOnlySpan<char> line = fullText.AsSpan();
int comma = line.IndexOf(',');
ReadOnlySpan<char> firstField = line.Slice(0, comma);
// no new string allocated for firstFieldBecause it's a ref struct, Span
The example that comes up constantly in interviews is parsing a large delimited line or a fixed-format packet without allocating a new string per field, slicing pieces off a span with IndexOf and Slice instead of calling something like Split on the whole string, which allocates a brand-new string object for every single field on every single row.
Normal .NET execution compiles IL to native machine code with the JIT, at runtime, method by method, the first time each one actually runs, which is why a freshly started app is slower for the first requests until the hot paths have warmed up, tiered compilation actually produces a quick, less-optimized version first, then recompiles hot methods with full optimizations in the background. ReadyToRun improves startup by pre-compiling most methods to native code at publish time and embedding that in the assembly, but it still ships the full runtime and can still fall back to the JIT for anything not covered, generic instantiations especially.
Native AOT compiles the entire application, including the runtime itself, straight into a single native executable ahead of time, there's no JIT present at runtime at all. Startup time and memory footprint drop substantially since there's no warmup and no need to ship the full runtime alongside the app, which matters a lot for CLI tools, serverless functions cold-starting on every invocation, and containers billed per second of startup.
The tradeoff is real. No runtime code generation means no dynamic reflection emit and no runtime assembly loading, and reflection-heavy code, a lot of traditional MVC model binding and older serializers, needs to be rewritten around source generators instead, since Native AOT can't discover types and members dynamically the way reflection normally does. That's why the Native AOT templates steer you toward minimal APIs paired with a source-generated JSON serializer rather than full MVC with runtime model binding.
The lock statement is syntactic sugar, the compiler expands it into entering a monitor before the block and exiting it in a finally block after, using an overload that takes a ref bool tracking whether the lock was actually acquired, specifically to guarantee the exit only runs if entry actually succeeded, avoiding a subtle bug in the older two-step expansion where an exception between entering and the try block could leave a lock held forever.
private readonly object _lock = new object();
public void Increment()
{
lock (_lock)
{
_count++;
}
}
// expands roughly to:
// bool lockTaken = false;
// try {
// Monitor.Enter(_lock, ref lockTaken);
// _count++;
// } finally {
// if (lockTaken) Monitor.Exit(_lock);
// }The monitor associates a sync block with the locked object's header in memory, whichever thread enters first gets the lock, every other thread calling enter just blocks until the owner exits. That's exactly why the object you lock on matters: locking on this is dangerous because any external code holding a reference to your object can also lock on it, from completely unrelated code, creating a deadlock or serialization you never intended and can't see from inside your own class. Locking on a type object or a public static field has the same problem at a wider scope, any other code with access to it can jam your lock.
The standard fix is a dedicated, private, readonly object that only your class ever sees, as in the example above. Nothing outside the class can ever get a reference to it, so nothing outside the class can accidentally, or maliciously, contend for or hold your lock.
Lazy loading in EF Core works by having the context generate a runtime proxy subclass of your entity, where every navigation property getter is overridden to check, on first access, whether it's already loaded, and if not, issue a query against the still-open context to fetch it right then. That's convenient to write, reading a related property just works without an explicit include, but it means the actual number and timing of queries executing is invisible at the call site. Someone iterating a list of five hundred orders and touching a related customer property on each one triggers five hundred separate round trips without a single line of code looking suspicious.
The sharper edge is context lifetime. Those proxies hold a reference back to the context that created them, and if an entity gets returned out of a scoped service after that context has already been disposed, easy to do if a controller returns an entity straight out of a repository whose using block already closed, touching an unloaded navigation property later throws a disposed-object exception at a spot in the code far away from where the context was actually disposed. That's a genuinely confusing bug to track down the first time you hit it.
Most teams either turn lazy loading off entirely and require explicit include calls, which also fixes the serialization trap where a JSON serializer walks navigation properties and quietly lazy-loads half the database into a response, or keep it but enforce that entities never leave the boundary of the request's context, mapping to a separate response model before returning anything.
catch with a when clause attaches a boolean condition to a catch block, the block only actually handles the exception if that condition evaluates true, otherwise the exception keeps propagating up as if that catch clause wasn't there at all. It looks like it could just be an if statement inside a plain catch block, checking the condition and rethrowing if it fails, but something meaningfully different happens under the hood.
An exception filter runs before the stack unwinds. The runtime evaluates the when condition while the exception is still sitting at its original throw point, walking up looking for a matching handler, and only unwinds the stack once it finds a catch whose type and filter both match. That means if you log the stack trace or attach a debugger from inside the filter itself, you're looking at the original crash site with all its state intact. The rethrow-based approach unwinds the stack into the catch block first, destructively, before your condition ever runs, so by the time you inspect anything you've already lost some of that original context.
The other practical use is retry logic without catching prematurely, a filter checking whether the current attempt count is below a maximum lets you leave the exception completely alone once retries are exhausted, so it surfaces normally to whatever outer handler or logging middleware expects to see it, instead of getting caught, inspected, and manually rethrown, which is exactly the kind of code that accidentally mangles a stack trace if someone rethrows the caught variable instead of using a bare throw.
Across the .NET-track mock interviews run through LastRoundAI, the recurring stumble isn't syntax. It's async. Candidates define async/await correctly on the first pass, then lose the thread the moment a follow-up asks them to trace what the compiler-generated state machine actually does, or why a specific .Result call would deadlock in one hosting model and not another. Confident definition, shaky mechanism, that gap shows up in the transcripts more than any other single pattern.
If you're prepping for a 2026 loop, put disproportionate time into GC generations, the DI captive-dependency scenario, and reproducing an async deadlock yourself in a throwaway console app. Reading an explanation isn't the same as saying it out loud correctly while someone watches over a video call, so practice each answer on this page in under 90 seconds before a real loop. That's usually the actual test.

