DotNet Interview Question
Technical Interview Guide: Welcome to this comprehensive, in-depth preparation guide featuring curated, real-world, and scenario-based technical interview questions and answers. Designed specifically for intermediate to advanced developers, this guide covers foundational concepts, internal framework architecture, performance optimization, multithreading, system design principles, and production troubleshooting to help you clear technical rounds with confidence.
.NET Interview Questions
Topics covered: ASP.NET Core Pipeline & Middleware, Dependency Injection, Hosting & Kestrel, Filters & Endpoints, CLR & GC Generations, Span<T> & Low-Allocation, IDisposable, Task vs ValueTask, ThreadPool Mechanics, Channels & Queues, EF Core Optimization & Tracking, Resilience (Polly), OAuth2/JWT Security, and Production Diagnostics.
ASP.NET Core Pipeline, Middleware & Filters
The request pipeline is composed of a bidirectional chain of
RequestDelegate instances structured like Russian nesting dolls. Each middleware receives the HttpContext, executes pre-processing logic, awaits next(context) to invoke the downstream component, and then executes post-processing logic on the return path.
app.Use(async (context, next) => {
// 1. Inbound processing (before endpoints)
await next(context);
// 2. Outbound processing (after response generation)
});
UseAuthorization before UseAuthentication) is one of the most common configuration bugs.Short-circuiting occurs when a middleware intentionally terminates request processing by returning without calling
await next(context). Common production examples include:
- Static File Middleware: Returns the file directly from disk and avoids invoking routing/controllers.
- Authentication/Authorization Middleware: Immediately sets a
401 Unauthorizedor403 Forbiddenstatus code if validation fails. - Rate Limiting / CORS Preflight: Returns an immediate
429 Too Many Requestsor204 No Contentfor OPTIONS requests.
* Middleware: Operates at the transport/protocol level globally across all incoming HTTP requests before routing resolves. Use for CORS, TLS termination headers, gzip compression, and global error handling.
* Action Filters: Run inside the MVC controller pipeline after model binding and routing. They have full access to
ActionExecutingContext, ModelState, and controller instances. Use for controller-specific parameter validation.* Endpoint Filters (.NET 7+): Designed for Minimal APIs and mapped endpoints. They offer lightweight cross-cutting validation and response mapping without the performance overhead of the MVC filter pipeline.
*
app.Use(): Connects a middleware delegate to the pipeline and allows delegating execution to the next handler via next.Invoke().*
app.Run(): Terminal middleware that terminates the pipeline and never invokes a downstream delegate.*
app.Map() / app.MapWhen(): Branches the pipeline based on URL prefix matching or conditional predicate evaluation.
* Conventional Middleware: Does not implement an interface. It requires a constructor taking
RequestDelegate and an InvokeAsync(HttpContext, ...) method. It is registered as a Singleton at startup; method-injected parameters are resolved per request.* Factory-Based Middleware (
IMiddleware): Implements IMiddleware, is activated per-request via IMiddlewareFactory, and is registered in the DI container as Scoped or Transient. It allows direct constructor injection of scoped services (e.g., DbContext) without captive dependency risks.
Once Kestrel begins streaming the response body back to the client, the HTTP status code and response headers are frozen and flushed over the TCP connection (
context.Response.HasStarted == true). Attempting to modify status codes or headers downstream will throw an InvalidOperationException.
context.Response.OnStarting() callbacks if a middleware needs to append headers right before they are flushed to the socket.In .NET 8+, use the
IExceptionHandler interface combined with app.UseExceptionHandler(). It avoids custom try-catch middleware and integrates with standard ProblemDetails (RFC 7807):
public class GlobalExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception ex, CancellationToken ct)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new ProblemDetails {
Title = "Server Error",
Detail = ex.Message,
Status = 500
}, ct);
return true; // Handled
}
}
Filters run in the following sequence:
- Authorization Filters: Evaluates identity, roles, and policies.
- Resource Filters: Executes before model binding (used for response caching / performance profiling).
- Action Filters: Runs immediately before and after the action method execution.
- Exception Filters: Handles unhandled exceptions thrown during action execution.
- Result Filters: Runs before and after the action result (e.g., view rendering or JSON serialization) is produced.
*
UseRouting(): Matches the incoming HTTP request URL and verb to an Endpoint object and stores the selection in HttpContext.GetEndpoint().*
UseEndpoints(): Actually invokes the selected endpoint delegate or controller action.Placing middleware between
UseRouting() and UseEndpoints() (e.g., UseAuthentication, UseAuthorization, UseCors) allows that middleware to inspect endpoint metadata (such as [Authorize] or [EnableCors]) before the endpoint runs.
Endpoint filters implement
IEndpointFilter and receive an EndpointFilterInvocationContext and an EndpointFilterDelegate. They can intercept arguments, run FluentValidation, and mutate return values:
app.MapPost("/orders", (Order order) => Results.Ok(order))
.AddEndpointFilter(async (context, next) => {
var order = context.GetArgument<Order>(0);
if (order.Amount <= 0)
return Results.BadRequest("Amount must be positive.");
return await next(context);
});
By default,
HttpRequest.Body is a forward-only, non-seekable network stream. To read it in custom logging or auditing middleware without breaking downstream model binding, call context.Request.EnableBuffering():
context.Request.EnableBuffering();
using (var reader = new StreamReader(context.Request.Body, leaveOpen: true))
{
var body = await reader.ReadToEndAsync();
context.Request.Body.Position = 0; // Rewind for downstream model binding
}
await next(context);
* Response Compression: Intercepts outbound streams and compresses payloads (Gzip, Brotli) based on the client's
Accept-Encoding request header.* Request Decompression (.NET 7+): Inspects the incoming
Content-Encoding header and wraps the request stream in a decompression stream (e.g., GZipStream) before model binding reads it.
IFeatureCollection is a low-overhead, decoupled mechanism for the server host (Kestrel/IIS) and middleware to expose optional protocol features to downstream consumers. Examples include IHttpConnectionFeature (remote IP/ports), IHttpRequestLifetimeFeature (abort signals), and ITlsConnectionFeature (client SSL certificates).
Conventional middleware instances are created once at application startup as Singletons. If you inject a
Scoped dependency (such as EF Core's DbContext) into the middleware's constructor, it becomes captured as a single instance for the entire application life cycle, causing cross-request data leaks and concurrency exceptions.Fix: Inject the scoped dependency directly as a parameter in the
InvokeAsync(HttpContext context, MyScopedService scoped) method.
ASP.NET Core provides built-in rate-limiting middleware (
app.UseRateLimiter()) powered by System.Threading.RateLimiting. Supported partitioner algorithms include:
- Fixed Window: Limits requests within static time intervals (e.g., 100 req/min).
- Sliding Window: Divides windows into segments to prevent request burst spikes at window boundaries.
- Token Bucket: Refills tokens at a constant rate, allowing controlled bursts when tokens accumulate.
- Concurrency Limiter: Enforces a strict maximum number of concurrent in-flight requests.
* Response Caching: Purely HTTP cache-control header driven. Relies on client/proxy cooperation, does not support server-side cache eviction, and will not cache authenticated requests.
* Output Caching: Full server-side caching engine. Supports cache tags, selective programmatic cache invalidation via
IOutputCacheStore.EvictByTagAsync(), cache locking to prevent cache stampedes (thundering herds), and custom storage backends like Redis.
Dependency Injection & Service Lifetimes
* Transient (
AddTransient): A new instance is created every single time it is requested from the service container. Best for lightweight, stateless utility classes.* Scoped (
AddScoped): A single instance is created once per client HTTP request connection/scope and reused throughout that request. Best for stateful per-request objects like EF Core's DbContext or user session contexts.* Singleton (
AddSingleton): A single instance is created either at registration/first resolution and shared across the entire application lifetime and all requests. Best for state caching, thread-safe background orchestrators, and metric collectors.
A captive dependency occurs when a service with a longer lifetime consumes a dependency with a shorter lifetime (most commonly, a Singleton holding a reference to a Scoped service).
Why it is dangerous:
- The scoped service (such as
DbContext) is held alive for the lifetime of the application instead of being disposed at the end of the HTTP request. - Since Singletons handle concurrent requests across multiple threads simultaneously, multiple threads end up calling the captive
DbContextinstance concurrently, throwing multi-threaded concurrency exceptions (InvalidOperationException: A second operation was started on this context instance before a previous operation completed).
ASP.NET Core enables two container validation flags automatically when the host runs in the Development environment:
ValidateScopes: Verifies that scoped services are not directly or indirectly resolved from the root singleton container.ValidateOnBuild: Validates that all registered service descriptors can be successfully constructed and have all dependencies satisfied at startup build time.
// Enabled by default in WebApplication.CreateBuilder(args) for Development:
builder.Host.UseDefaultServiceProvider((context, options) => {
options.ValidateScopes = context.HostingEnvironment.IsDevelopment();
options.ValidateOnBuild = context.HostingEnvironment.IsDevelopment();
});
Inject
IServiceScopeFactory into the singleton or background service constructor and manually create an isolated scope for each discrete unit of work using a using block:
public class QueueProcessor : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public QueueProcessor(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using (var scope = _scopeFactory.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.ProcessPendingQueueAsync(stoppingToken);
}
await Task.Delay(5000, stoppingToken);
}
}
}
Before .NET 8, registering multiple implementations of the same interface required writing custom factory delegates or service locators. .NET 8 introduced first-class keyed service registrations:
// Registration
builder.Services.AddKeyedSingleton<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedSingleton<INotificationSender, SmsSender>("sms");
// Resolution via constructor
public class NotificationManager(
[FromKeyedServices("email")] INotificationSender emailSender,
[FromKeyedServices("sms")] INotificationSender smsSender)
{
// ...
}
*
GetService<T>(): Returns null if the service T is not registered in the DI container. Best for optional dependencies.*
GetRequiredService<T>(): Immediately throws an InvalidOperationException: No service for type 'T' has been registered if the type is missing. Best for mandatory dependencies because it fails fast at the point of resolution rather than causing a downstream NullReferenceException.
The DI container tracks every created service instance that implements
IDisposable or IAsyncDisposable:
- Transient & Scoped services: Disposed automatically when their containing
IServiceScopeis disposed (e.g., at the end of the HTTP request). - Singleton services: Disposed automatically when the root service provider is disposed during application shutdown.
services.AddSingleton(new MyService())) are not disposed by the container. If the container didn't instantiate it, it doesn't dispose it.If a transient service implements
IDisposable and is resolved multiple times from the Root/Singleton Provider (outside an active scope), the root container holds a reference to every single created instance in its internal disposal tracking list until the application terminates. This causes a stealthy memory leak over time.
Use the
typeof() syntax omitting generic type arguments:
// Registers IRepository<T> -> EfRepository<T>
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
The container will dynamically construct and satisfy concrete closed generic requests (e.g., IRepository<Order> resolves to EfRepository<Order>).
*
AddSingleton<IService, Impl>(): Appends the registration unconditionally. Multiple calls register multiple implementations under IEnumerable<IService>.*
TryAddSingleton<IService, Impl>(): Registers the implementation only if no implementation has been registered for IService yet.*
TryAddEnumerable(ServiceDescriptor.Singleton<IService, Impl>()): Adds the implementation only if that exact concrete implementation has not already been registered for the interface.
Service Locator involves passing
IServiceProvider directly into classes so they can resolve their own dependencies internally:
- Hides Dependencies: Public class constructors no longer declare what dependencies are required to operate, leading to runtime failures instead of compile-time verification.
- Hampers Testability: Unit tests must mock the entire service provider and its internal graph instead of simply passing mocked interfaces to a constructor.
The default Microsoft DI container adheres to the following resolution rules:
- If a constructor is marked with the
[ActivatorUtilitiesConstructor]attribute, it is always selected. - Otherwise, the container selects the constructor with the greatest number of parameters that can all be satisfied by registered services in the container.
- If multiple constructors have the same highest number of resolvable parameters, the container throws an
InvalidOperationExceptiondue to ambiguity.
ActivatorUtilities.CreateInstance<T>(IServiceProvider, params object[] manualArgs) is a helper that instantiates a class whose constructor requires a hybrid of DI-resolved dependencies and explicit runtime arguments (e.g., dynamic IDs, runtime connection strings) without registering the target type in DI.
The built-in container is designed as a minimal, high-performance container. It lacks advanced enterprise features such as:
- Assembly Scanning / Auto-registration: Supported easily by adding the Scrutor library (
scan.FromAssembly().AddClasses()...). - Property & Method Injection: Microsoft DI only supports constructor injection and action/endpoint parameter injection.
- Decorator & Interception Patterns: Built-in dynamic proxy generation is absent without third-party containers like Autofac or Castle Windsor.
Using the popular
Scrutor extension, you can wrap registered implementations with decorator classes (such as caching or logging decorators) transparently:
builder.Services.AddScoped<IOrderService, OrderService>();
// Wraps OrderService with CachedOrderService transparently
builder.Services.Decorate<IOrderService, CachedOrderService>();
*
IOptions<TOptions>: Registered as a Singleton. Reads configuration once at startup and never updates if the underlying appsettings.json changes.*
IOptionsSnapshot<TOptions>: Registered as Scoped. Recomputes configuration once per HTTP request, picking up configuration changes without restarting the application. Cannot be injected into Singletons.*
IOptionsMonitor<TOptions>: Registered as a Singleton. Provides real-time dynamic configuration updates via the CurrentValue property and supports change notification events (OnChange). Safe to inject into Singletons.
Hosting, Kestrel & Web Server Architecture
Kestrel is the cross-platform, asynchronous, event-driven HTTP web server for ASP.NET Core built directly on top of
System.IO.Pipelines and low-level socket abstractions.
Its internal architecture separates:
- Transport Layer: Handles raw TCP connection sockets (default: cross-platform Socket transport, or Linux
io_uring). - Connection Layer: Uses memory pools (
MemoryPool<byte>) to pass contiguous network buffers without intermediate heap allocations. - HTTP Layer: Parses HTTP/1.1, HTTP/2, and HTTP/3 frames, constructing the high-level
HttpContextpassed into the middleware pipeline.
While Kestrel is edge-ready, production microservices and enterprise applications run behind reverse proxies for several operational reasons:
- Port Sharing & Multiplexing: Allows multiple independent applications on the same server to share public ports
80and443. - Edge Security & DDoS Mitigation: Offloads slow-loris attacks, connection timeouts, and packet-level rate limiting before network frames reach the CLR runtime.
- TLS/SSL Termination & Offloading: Consolidates certificate renewals (e.g., Let's Encrypt) and offloads cryptographic decryption overhead from managed .NET threads.
- Static File Offloading: Serves non-dynamic assets (HTML, images, client bundles) directly via kernel space (
sendfile) without warming .NET ThreadPool workers.
When a reverse proxy terminates SSL or proxies traffic, Kestrel sees all requests originating from the proxy's internal IP (e.g.,
127.0.0.1) over plain http.
app.UseForwardedHeaders() reads headers like X-Forwarded-For and X-Forwarded-Proto to update HttpContext.Connection.RemoteIpAddress and HttpContext.Request.Scheme (switching http back to https). This ensures redirect URLs, OAuth callbacks, and IP-based rate limiting work accurately.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear(); // Ensure trusted subnets are configured for security
});
* Generic Host (
Host.CreateDefaultBuilder): Traditional two-stage hosting pattern (.NET Core 3.x / .NET 5) separating DI service registration (ConfigureServices) and pipeline setup (Configure) into a separate Startup.cs class.* WebApplication Builder (.NET 6+): Introduces a unified Minimal Hosting model where
WebApplicationBuilder and WebApplication exist in a single linear file (Program.cs), exposing immediate access to configuration, logging, and environment flags during service registration.
* HTTP/2: Uses single persistent TCP connections with binary streaming multiplexing, reducing connection overhead and eliminating Head-of-Line (HoL) blocking at the application level.
* HTTP/3 (QUIC): Replaces TCP with UDP-based QUIC protocol. It solves transport-level Head-of-Line blocking (packet loss on one stream does not stall other streams) and enables zero-RTT handshakes and connection migration across networks.
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(5001, listenOptions =>
{
listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
listenOptions.UseHttps();
});
});
YARP is a high-performance, programmable reverse proxy library built by Microsoft entirely in C# on top of ASP.NET Core and
SocketsHttpHandler infrastructure.
Key Capabilities:
- Dynamic route matching and cluster forwarding configured via
appsettings.jsonor code providers. - Active and passive health checking of downstream destinations.
- Built-in load-balancing algorithms (RoundRobin, LeastRequests, PowerOfTwoChoices).
- Customizable routing transformations and integration with standard ASP.NET Core auth/rate-limiting middleware.
When Kestrel receives a termination signal (
SIGTERM / container stop):
- The server stops accepting new incoming TCP connections.
- The host triggers the
IHostApplicationLifetime.ApplicationStoppingcancellation token. - Kestrel waits up to the configured
ShutdownTimeout(default: 30 seconds) for existing in-flight HTTP requests to complete cleanly. - Hosted services (
IHostedService.StopAsync) execute their termination logic in reverse registration order.
builder.Services.Configure<HostOptions>(opts =>
{
opts.ShutdownTimeout = TimeSpan.FromSeconds(45);
});
*
IHostedService: The base interface defining StartAsync(CancellationToken) and StopAsync(CancellationToken). StartAsync must return quickly because long-blocking operations in it will block application startup.*
BackgroundService: An abstract base class implementing IHostedService that handles task lifecycle management and cancellation orchestration, requiring you to implement only the long-running ExecuteAsync(CancellationToken) loop.
Kestrel provides configuration knobs via
KestrelServerLimits to control memory consumption and connection concurrency:
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxConcurrentConnections = 10000;
options.Limits.MaxConcurrentUpgradedConnections = 5000; // WebSockets/gRPC
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
options.Limits.MinRequestBodyDataRate = new MinDataRate(bytesPerSecond: 240, gracePeriod: TimeSpan.FromSeconds(5));
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
});
* In-Process Hosting: The ASP.NET Core app executes directly inside the IIS worker process (
w3wp.exe). The ASP.NET Core Module (ANCM) loads the .NET CLR directly, avoiding loopback network serialization and delivering maximum throughput.* Out-of-Process Hosting: IIS acts purely as an external reverse proxy forwarding incoming HTTP traffic over an internal loopback port to a standalone process running Kestrel (
dotnet.exe).
System.IO.Pipelines is a high-performance I/O library designed to eliminate buffer allocations, memory copies, and thread-synchronization overhead when parsing network streams.
Instead of allocating temporary byte arrays (e.g.,
byte[] buffer = new byte[4096]), the PipeReader reads directly into rented memory segments from an ArrayPool or native memory pool. Kestrel parses HTTP frame headers directly over sequence segments without copying data to managed strings until required.
.NET 8 introduced
IHostedLifecycleService, which extends IHostedService with discrete, fine-grained lifecycle hooks executing before and after startup/shutdown phases:
StartingAsync&StartedAsyncStartAsyncStoppingAsync&StoppedAsyncStopAsync
.NET 8 introduced built-in Request Timeout middleware (
app.UseRequestTimeouts()). It allows configuring global or endpoint-specific policies that trigger cancellation on the HttpContext.RequestAborted token if an operation exceeds a time boundary:
builder.Services.AddRequestTimeouts(options => {
options.DefaultPolicy = new RequestTimeoutPolicy {
Timeout = TimeSpan.FromSeconds(5),
TimeoutStatusCode = StatusCodes.Status504GatewayTimeout
};
});
// Applied to endpoint:
app.MapGet("/heavy-task", async (CancellationToken ct) => {
await Task.Delay(10000, ct);
return Results.Ok();
}).WithRequestTimeout(TimeSpan.FromSeconds(2));
* WebSockets: Full-duplex, bidirectional communication over a single TCP socket upgraded via HTTP/1.1 or HTTP/2. Ideal for chat, collaborative tools, and gaming.
* Server-Sent Events (SSE): Unidirectional (server-to-client) streaming over standard HTTP connections sending
text/event-stream payloads. SSE automatically supports reconnects and avoids WebSocket proxy traversal issues.
In a
BackgroundService, the ExecuteAsync method is invoked by the host and awaited until the first asynchronous yield (await).
To prevent expensive initialization (e.g., loading remote configuration or heavy warmups) from blocking other hosted services during application startup, yield execution back to the caller using
await Task.Yield():
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Yield immediately so host startup proceeds concurrently
await Task.Yield();
await DoHeavyWarmupAsync(stoppingToken);
}
ASP.NET Core provides
AddHealthChecks() and MapHealthChecks() to expose endpoint probes for orchestrators like Kubernetes:
- Startup Probe (
/healthz/startup): Checks if heavy one-time initialization (database migrations/cache warming) is finished. - Liveness Probe (
/healthz/live): Determines if the process is running. If it fails, orchestrators restart the container. - Readiness Probe (
/healthz/ready): Validates that downstream dependencies (SQL, Redis, RabbitMQ) are reachable before load balancers route user traffic to the pod.
Filters, Endpoints & Routing
The filter pipeline runs inside the MVC action execution cycle in the following strict chronological sequence:
- Authorization Filters (
IAuthorizationFilter): Determines whether the request identity is authorized to access the action. Always executes first. - Resource Filters (
IResourceFilter): Runs before model binding occurs. Used for early performance caching and short-circuiting. - Action Filters (
IActionFilter/IAsyncActionFilter): Executes immediately before (OnActionExecuting) and after (OnActionExecuted) the controller action method runs, providing access to bound arguments andModelState. - Exception Filters (
IExceptionFilter): Catches unhandled exceptions thrown during controller constructor instantiation, model binding, action filters, or action method execution. - Result Filters (
IResultFilter): Executes immediately before and after the action result (e.g., View rendering, JSON serialization) is processed.
* Action Filters: Tightly coupled to the MVC controller architecture. They require reflection over controller types, full model binding context setup, and the complete MVC pipeline overhead.
* Endpoint Filters (
IEndpointFilter): Lightweight, cross-cutting filters designed primarily for Minimal APIs (and endpoint-routed controllers). They operate directly on the route handler delegate using EndpointFilterInvocationContext, allowing parameter inspection and result mutation with near-zero allocation overhead.
Endpoint filters can be registered using inline lambda expressions, filter factory methods, or dedicated classes implementing
IEndpointFilter:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var arg = context.Arguments.OfType<T>().FirstOrDefault();
if (arg is null)
return Results.BadRequest("Invalid payload.");
// Execute downstream filters / endpoint handler
var result = await next(context);
return result;
}
}
// Chaining to an endpoint:
app.MapPost("/orders", (Order order) => Results.Ok(order))
.AddEndpointFilter<ValidationFilter<Order>>()
.AddEndpointFilter(async (context, next) => {
// Secondary inline filter logic
return await next(context);
});
Introduced in .NET 7,
app.MapGroup() organizes endpoints with a shared URL prefix and applies cross-cutting metadata (such as authentication, rate limiting, and endpoint filters) across all routes in that group simultaneously:
var usersGroup = app.MapGroup("/api/users")
.RequireAuthorization("AdminPolicy")
.RequireRateLimiting("fixed-window")
.AddEndpointFilter<AuditLogFilter>();
usersGroup.MapGet("/", GetAllUsers);
usersGroup.MapGet("/{id:guid}", GetUserById);
*
[ServiceFilter(typeof(MyFilter))]: Resolves the filter instance directly from the DI container. MyFilter must be explicitly registered in IServiceCollection (e.g., AddScoped<MyFilter>()).*
[TypeFilter(typeof(MyFilter))]: Uses ObjectFactory to instantiate the filter via DI without requiring explicit registration in IServiceCollection. It allows passing non-DI constructor parameters.*
IFilterFactory: An interface implemented by attributes to create filter instances dynamically with full control over instance reuse and lifetime scope.
Setting the
Result property on the provided context (e.g., ActionExecutingContext.Result or ResourceExecutingContext.Result) to a non-null IActionResult immediately halts subsequent filters and skips action execution, directly invoking the result execution phase.
public class CustomAuthFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
if (!context.HttpContext.Request.Headers.ContainsKey("X-Api-Key"))
{
// Short-circuit pipeline
context.Result = new UnauthorizedObjectResult("API Key required.");
return;
}
await next();
}
}
* Exception Middleware (
UseExceptionHandler / IExceptionHandler): Operates globally at the HTTP pipeline level. It catches exceptions from all middleware, endpoints, static files, routing, and controller execution.* Exception Filter (
IExceptionFilter): Scoped strictly to the MVC controller pipeline. It only catches exceptions thrown inside controller constructors, action filters, model binding, and action methods. It cannot catch exceptions occurring outside MVC (such as in preceding middleware or static file serving).
IExceptionHandler) for global API error responses to guarantee catching all server faults.Endpoint routing uses a high-performance DFA (Deterministic Finite Automaton) tree matcher:
UseRouting()evaluates the incoming URL and HTTP method against compiled route patterns in the DFA graph.- It identifies the winning route and assigns the selected
Endpointinstance toHttpContext.GetEndpoint(). - The endpoint holds associated metadata (e.g.,
AuthorizeAttribute,CorsPolicy, custom metadata), allowing intervening middleware to inspect constraints beforeUseEndpoints()executes the delegate.
Route constraints validate URL segments during route matching (e.g.,
{id:int}, {guid:guid}, {slug:minlength(3)}).
Risk of Regex Constraints (
{name:regex(^...$)}): Complex, uncompiled regular expressions evaluated on high-throughput URL paths can cause CPU spikes, backtracking overhead, and ReDoS vulnerabilities. Prefer dedicated custom IRouteConstraint implementations over heavy regex matching.
IAsyncResourceFilter executes after authorization filters but before model binding and action filter execution.
It is primarily used for:
- Custom Output Caching: Serving cached responses before expensive model binding and object allocations occur.
- Request Body Transformation: Pre-processing or wrapping request streams before parameter deserialization.
Attach metadata using
.WithMetadata() or custom attributes on endpoint definitions, then retrieve it from the endpoint metadata collection:
// Definition
app.MapGet("/feature", () => Results.Ok())
.WithMetadata(new FeatureFlagAttribute("NewCheckoutFlow"));
// Inspection inside an Endpoint Filter:
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var endpoint = context.HttpContext.GetEndpoint();
var feature = endpoint?.Metadata.GetMetadata<FeatureFlagAttribute>();
if (feature != null && !IsFeatureEnabled(feature.Name))
{
return Results.NotFound();
}
return await next(context);
}
Model binding retrieves data from various HTTP request sources, converts string values to target .NET types, and populates action/endpoint parameters.
Explicit source binding attributes include:
[FromBody]: Deserializes the request body using configured formatters (System.Text.Json).[FromRoute]: Extracts values from route template parameters.[FromQuery]: Parses query string parameters.[FromHeader]: Reads HTTP request headers.[FromForm]: Binds multipart/form-data or form-urlencoded fields.[AsParameters](.NET 7+): Binds complex flat objects/structs aggregating route, query, and header values in Minimal APIs.
*
Results.Ok(data): Returns an IResult boxing the return type. It lacks compile-time return type safety, requiring explicit [ProducesResponseType] attributes for complete OpenAPI/Swagger schema generation.*
TypedResults.Ok(data): Returns concrete strongly-typed structs (e.g., Ok<UserDto>, NotFound, BadRequest<ProblemDetails>). It enables exact OpenAPI response metadata inference without extra annotations and allows type-safe unit testing without boxing.
By default, filter stages execute in order of decreasing scope for the entry path, and increasing scope for the return path:
- Entry (Before Action): Global Filters → Controller-level Filters → Action-level Filters.
- Exit (After Action): Action-level Filters → Controller-level Filters → Global Filters.
IOrderedFilter and setting the Order integer property (lower numbers execute earlier on entry).A custom model binder (implementing
IModelBinder) customizes how incoming strings are transformed into domain types.
Common use cases:
- Parsing comma-separated query strings into a typed array or collection (e.g.,
?ids=1,2,3→int[]). - Decrypting encrypted URL parameters or parsing custom binary payload formats before reaching the action.
Implement
IRouteConstraint and register the key in RouteOptions:
public class SlugConstraint : IRouteConstraint
{
public bool Match(HttpContext? httpContext, IRouter? route, string routeKey,
RouteValueDictionary values, RouteDirection routeDirection)
{
if (values.TryGetValue(routeKey, out var value) && value is string text)
{
return text.All(c => char.IsLetterOrDigit(c) || c == '-');
}
return false;
}
}
// Registration in Program.cs:
builder.Services.Configure<RouteOptions>(options => {
options.ConstraintMap.Add("slug", typeof(SlugConstraint));
});
// Usage: app.MapGet("/articles/{title:slug}", GetArticle);
Garbage Collection, Memory Generations & POH
The .NET GC is a generational, tracing, mark-and-compact/sweep collector based on the generational hypothesis (most objects die shortly after creation).
It partitions managed memory into three generational categories:
- Generation 0: The newest, short-lived ephemeral objects (e.g., local variables, short strings). Collected frequently with sub-millisecond pauses.
- Generation 1: Serves as a buffer generation between short-lived and persistent long-lived objects.
- Generation 2: Long-lived objects (e.g., Singletons, static caches, surviving Gen 1 objects). Collected during full GC sweeps.
Every GC collection follows three core phases:
- Marking Phase: The GC traverses object reference graphs starting from root references (stack pointers, CPU registers, static fields, GC handles). All reachable live objects are marked.
- Plan Phase: The GC calculates the simulation cost of compaction versus sweeping based on fragmentation levels and generational boundaries.
- Relocate / Compact (or Sweep) Phase: Unmarked dead memory is reclaimed. In compaction, surviving live objects are moved to create contiguous free space, and all corresponding memory pointers are updated. In sweep mode, dead gaps are added to free lists without relocating objects.
The Large Object Heap is a dedicated segment of the managed heap used for large allocations:
- Threshold: Any object whose payload size is ≥ 85,000 bytes (such as large arrays, byte buffers, or large string instances) is allocated directly on the LOH.
- Collection Behavior: Objects on the LOH are considered part of Generation 2 and are collected only during full Gen 2 GC cycles.
- No Default Compaction: To avoid massive memory copying CPU costs, the LOH is swept rather than compacted by default, which can cause severe virtual memory fragmentation if large temporary arrays are repeatedly allocated.
When managed objects (like byte arrays) are passed to native OS APIs or sockets, they must be pinned in place so the GC does not move them during compaction.
The Problem: Pinned objects in Gen 0/1/2 create "islands" of unmovable memory, causing severe heap fragmentation because the GC cannot compact around pinned boundaries.
The Solution (POH): The POH is a specialized heap segment specifically for pinned allocations (e.g., using
GC.AllocateArray<byte>(length, pinned: true)). Because all pinned objects are isolated on their own heap, Gen 0/1/2 heaps remain completely unfragmented and free to compact efficiently.
* Workstation GC: Designed for desktop/UI responsiveness. Uses a single shared managed heap and a single background GC thread. It minimizes latency and pauses to keep UI interactions smooth.
* Server GC: Designed for backend throughput. Creates a dedicated managed heap and dedicated GC thread per logical CPU core. Collections execute in parallel across all CPU heaps simultaneously to maximize processing throughput on multi-core servers (default in ASP.NET Core).
Full Gen 2 collections can take tens or hundreds of milliseconds. Background GC runs Gen 2 collections concurrently on a separate background thread without suspending application worker threads (No Stop-The-World for the majority of the Gen 2 sweep).
Additionally, Background GC allows short ephemeral collections (Gen 0/1) to preempt the background Gen 2 collection, keeping high-frequency allocations flowing smoothly without waiting for Gen 2 to complete.
Managed memory leaks occur when references remain rooted in long-lived objects, preventing the GC from reclaiming them:
- Unsubscribed Event Handlers: A long-lived publisher holding a delegate reference to a subscriber prevents the subscriber from being collected.
- Static Collections & Caches: Unbounded static
ConcurrentDictionaryorListinstances growing indefinitely. - Captive Scoped Dependencies: Scoped services captured by Singletons.
- Async Lifetime Captures: Closures capturing large outer objects in long-running background tasks.
- Unmanaged Resource Leaks: Failing to dispose unmanaged handles, bitmaps, or native COM wrappers.
ArrayPool<T>.Shared manages a thread-safe pool of reusable array buffers. Instead of allocating a new byte[] on Gen 0 or LOH for streaming, serialization, or file I/O:
byte[] buffer = ArrayPool<byte>.Shared.Rent(65536);
try
{
int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 65536));
ProcessData(buffer, bytesRead);
}
finally
{
// Return array to pool to avoid GC collection
ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
}
Rent(minSize) may return an array larger than requested, so always slice using the exact operational length.While calling
GC.Collect() manually is an anti-pattern in high-throughput hot paths, you can request on-demand LOH compaction during planned maintenance or low-traffic windows:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
* Strong Reference (Default): Direct reference to an object. As long as a strong reference is reachable, the GC will never collect the object.
* WeakReference<T>: References an object without preventing the GC from reclaiming it. If memory pressure triggers a collection, the object is collected, and
weakRef.TryGetTarget(out var target) returns false. Used for memory-sensitive caching.
Introduced in .NET 8, DATUM enables the Server GC to dynamically scale its heap count and memory footprint based on actual application workload demand rather than strictly allocating dedicated heaps based on total system CPU cores. This allows multi-tenant container workloads to consume less idle memory.
GCHandle provides a way to interact with managed objects from native code or manipulate GC tracking behavior:
GCHandleType.Normal: Creates an explicit managed root.GCHandleType.Pinned: Prevents the GC from moving the object in memory (allows passing native pointers).GCHandleType.Weak: Tracks an object without keeping it alive (allows finalization).GCHandleType.WeakTrackResurrection: Tracks an object even after it has been resurrected by a finalizer.
Object Resurrection occurs when an object's Finalizer (
~ClassName()) assigns a reference to this to a globally reachable live variable (such as a static list) during finalization:
~MyClass()
{
GlobalCache.ResurrectedObjects.Add(this); // Resurrected!
}
The object becomes live again, surviving the collection. However, its finalizer will not execute again unless GC.ReRegisterForFinalize(this) is explicitly invoked.
GC.GetGCMemoryInfo() returns real-time diagnostic telemetry regarding memory pressure, including:
TotalAvailableMemoryBytes: Memory limit configured for the container (cgroups limit).MemoryLoadBytes: Current physical memory load.HeapSizeBytes/FragmentedBytes: Total size and fragmentation metrics across Gen 0/1/2/LOH/POH.
Calling
GC.Collect() manually:
- Forces all threads to pause (Stop-The-World).
- Prematurely promotes short-lived Gen 0/1 objects into Gen 2 before they have had a chance to die naturally, polluting the long-lived heap.
- Disrupts the GC's self-tuning heuristics, causing future automated sweeps to miscalculate heap growth budgets and collection intervals.
*
dotnet-counters: Monitor real-time GC metrics: dotnet-counters monitor --counters System.Runtime --process-id <PID> (tracks gc-pause-ratio, gen-0-gc-rate, loh-size).*
dotnet-gcdump: Captures fast, lightweight heap memory graphs: dotnet-gcdump collect -p <PID>.*
dotnet-trace / PerfView: Traces GC allocation tick events and exact call-tree stack traces to find high-frequency allocating methods.
Span<T>, Memory<T> & Low-Allocation Programming
Span<T> is a type-safe, memory-safe representation of a contiguous region of arbitrary memory. It consists internally of a managed pointer (ref byte or ref T) and a length integer.
It can point to:
- Managed Heap Memory: Slicing managed arrays or strings without allocating sub-arrays/strings.
- Stack-Allocated Memory: Memory allocated via
stackalloc. - Native/Unmanaged Memory: Pointers returned from native memory allocators (
Marshal.AllocHGlobal).
// Zero-allocation string parsing
ReadOnlySpan<char> text = "2026-09-01";
ReadOnlySpan<char> yearSpan = text.Slice(0, 4);
int year = int.Parse(yearSpan); // No intermediate string allocated!
Span<T> contains an interior pointer directly into memory. If that memory is on the stack, allowing Span<T> to escape to the heap would result in dangerous dangling pointer corruptions once the stack frame pops.
Therefore,
Span<T> is declared as a ref struct, which the C# compiler strictly enforces must live only on the execution stack:
- Cannot be boxed to
object,ValueType, or any interface. - Cannot be a field of a normal class or non-ref struct.
- Cannot be used across
awaitoryield returnboundaries (because async state machines compile into heap-allocated classes). - Cannot be captured inside lambda expressions or closures.
- Cannot be used as a generic type argument in standard classes (e.g.,
List<Span<int>>is illegal).
*
Span<T>: Pure stack-only type for synchronous, high-throughput compute and parsing loops.*
Memory<T>: A standard struct (not a ref struct) that represents a contiguous region of memory stored on the heap or stack.When to use
Memory<T> / ReadOnlyMemory<T>:
- When memory buffers need to be stored as fields in regular classes.
- When passing buffers across asynchronous
awaitboundaries (e.g.,Stream.ReadAsync(Memory<byte>)). - When work items with buffer slices are queued to the
ThreadPool.
Span<T> from a Memory<T> at any time using memory.Span.stackalloc allocates memory directly on the execution call stack instead of the managed GC heap. When paired with Span<T>, it does not require an unsafe code block:
// Allocates 256 bytes directly on the call stack
Span<byte> buffer = stackalloc byte[256];
FormatData(buffer);
// Memory is reclaimed automatically when the method returns (zero GC cost)
stackalloc sizes with a threshold check (e.g., $\le$ 512–1024 bytes) and fall back to ArrayPool<T> for larger payloads to prevent fatal StackOverflowException crashes.*
string.Substring(start, length): Allocates a brand new string object on the Gen 0 GC heap and copies the characters into it.*
string.AsSpan(start, length): Creates a ReadOnlySpan<char> pointing directly into the existing string's character array in place. It performs zero memory allocations, zero buffer copies, and executes in $O(1)$ time.
*
ISpanParsable<TSelf>: Enables static abstract parsing directly from ReadOnlySpan<char> without allocating intermediate strings (e.g., int.Parse(span), Guid.Parse(span), IPAddress.Parse(span)).*
ISpanFormattable: Formats primitive and domain types directly into a destination Span<char> buffer via TryFormat(), completely eliminating string allocation during serialization and logging.
Span<char> destination = stackalloc char[36];
Guid.NewGuid().TryFormat(destination, out int charsWritten);
*
MemoryMarshal: Provides interop methods to reinterpret spans and memory slices without copying bytes (e.g., casting ReadOnlySpan<byte> directly to ReadOnlySpan<int> using MemoryMarshal.Cast<TFrom, TTo>).*
Unsafe: Provides raw pointer-like operations without safety checks (e.g., Unsafe.AsRef, Unsafe.Add), bypassing array bounds-checking inside critical hot loops.
*
ArrayPool<T>: Returns rented raw managed arrays (T[]). Fast and direct, but requires manual tracking and calling Return(array) inside finally blocks.*
MemoryPool<T>: An abstraction returning an IMemoryOwner<T>. Calling Dispose() on the IMemoryOwner automatically returns the underlying leased memory to the pool. Ideal for asynchronous pipeline pipelines (System.IO.Pipelines).
Utf8JsonReader is a high-performance, low-allocation, forward-only tokenizer for UTF-8 encoded JSON payloads:
- It operates directly over
ReadOnlySpan<byte>orReadOnlySequence<byte>. - It parses keys, numbers, and strings directly from UTF-8 bytes without decoding them to UTF-16 C# strings (
string), drastically reducing heap allocations during JSON parsing.
When streaming data over network sockets or files, data chunks arrive segmented across multiple non-contiguous memory buffers leased from pools.
ReadOnlySequence<T> represents a linked sequence of contiguous memory segments. It allows algorithms to parse streams across buffer boundaries using a SequenceReader<T> without requiring segments to be merged into a single huge contiguous array.
*
string.Create(length, state, action): Pre-allocates the exact destination string on the heap once and gives access to its internal buffer as a Span<char> to populate characters before making it immutable.*
DefaultInterpolatedStringHandler (.NET 6+): Replaces traditional string.Format and boxing with a compiler-optimized ref struct that formats values directly into stack-allocated spans, skipping allocations when interpolations are omitted (e.g., disabled log levels).
When renting a buffer from
ArrayPool<T> and converting it to Memory<T>:
- Use-After-Free: If a method returns the array to the pool while a background thread or async continuation still holds a reference to the sliced
Memory<T>, data corruption occurs as other operations overwrite the pooled array. - Memory Leakage: Failing to return rented arrays causes the pool to allocate new arrays continually, driving memory consumption up.
In modern .NET, use
MemoryExtensions.EnumerateSplits or custom span-based line enumerators instead of string.Split() (which allocates an array of strings):
ReadOnlySpan<char> csvLine = "101,John,Doe,Engineer";
foreach (var range in csvLine.Split(','))
{
ReadOnlySpan<char> segment = csvLine[range];
// Process each column without a single heap allocation
}
SearchValues<T> is a highly optimized, immutable lookup set designed for searching characters or bytes within spans using vectorized SIMD hardware instructions (AVX2, SSE4, ARM NEON).
private static readonly SearchValues<char> ValidChars =
SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_");
public bool IsValid(ReadOnlySpan<char> input)
{
// Evaluates dozens of characters in parallel via hardware vector registers
return !input.ContainsAnyExcept(ValidChars);
}
* Large
struct passed by value: Copies the entire byte payload on every method invocation, degrading CPU cache and throughput.*
in T parameter: Passes the struct by read-only reference, preventing copying. However, if a non-readonly struct calls a method, the compiler generates a defensive copy to prevent mutations.*
readonly ref struct: Combines stack-only lifetime guarantees with full immutability, allowing the runtime to avoid all defensive copies and pass references cleanly.
CollectionsMarshal provides zero-copy unsafe access to internal backing buffers of collection types:
CollectionsMarshal.AsSpan(list): Exposes internalList<T>backing arrays directly asSpan<T>for fast vectorized traversal without indexer bounds-checking overhead.CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out bool exists): Accesses dictionary values by reference (ref TValue), eliminating the double hash lookup ofContainsKey+ indexer assignment.
IDisposable, Finalization & Resource Management
The standard pattern coordinates deterministic cleanup (called by the user/framework) with non-deterministic fallback cleanup (called by the GC finalizer):
public class ResourceHolder : IDisposable
{
private bool _disposed;
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // Skips finalizer thread queue
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// Free managed IDisposable resources (e.g., Streams, DbContexts)
}
// Free unmanaged resources (e.g., Native Handles, Pointers, COM objects)
_disposed = true;
}
~ResourceHolder() // Finalizer fallback
{
Dispose(disposing: false);
}
}
disposing == false (finalizer call), you must not touch managed objects because they may have already been collected or finalized by the GC.When an object with a finalizer is instantiated, the runtime places a pointer to it on the internal Finalization Queue.
Calling
GC.SuppressFinalize(this) removes the object from this queue. This is essential because:
- It informs the GC that all unmanaged resources are already released deterministically.
- It prevents the object from being promoted to the F-Reachable queue and surviving an extra GC collection cycle into Generation 1 or Generation 2.
*
IDisposable.Dispose(): Deterministic. Invoked immediately by application code or using statements on the calling thread. Safe to release both managed and unmanaged dependencies.* Finalizer (
~ClassName()): Non-deterministic. Invoked by the CLR's single dedicated finalizer thread at an unpredictable time after the object becomes unreachable. It degrades GC performance and cannot safely access managed reference types.
IAsyncDisposable defines ValueTask DisposeAsync(), allowing cleanup operations that perform asynchronous I/O (e.g., flushing network streams, writing closing frames to WebSockets, returning distributed locks) without blocking worker threads:
public async Task ProcessDataAsync()
{
await using var stream = new FileStream("data.bin", FileMode.Open);
await WritePayloadAsync(stream);
} // DisposeAsync is awaited asynchronously here
SafeHandle (e.g., SafeFileHandle, SafeWaitHandle) is a managed wrapper around operating system handles:
- Guaranteed Finalization (CER): Inherits from
CriticalFinalizerObject, guaranteeing execution even in thread-abort or out-of-memory edge cases. - Prevents Handle Recycling Attacks: Tracks reference counts internally to prevent native race conditions where an OS closes a recycled handle still in use.
- Eliminates Finalizers: Classes wrapping unmanaged handles via
SafeHandledo not need their own finalizer, simplifying the class design to standard managed disposal.
* Classic
using (...) { } block: Scopes resource disposal strictly within curly braces.*
using var ... declaration: Disposes the resource automatically at the end of the enclosing variable scope (e.g., method exit or block end).Both compile to a
try-finally block that checks for null before invoking Dispose():
var resource = new ResourceHolder();
try {
resource.DoWork();
}
finally {
if (resource != null) ((IDisposable)resource).Dispose();
}
An
ObjectDisposedException should be thrown at the beginning of any public method or property accessor if called on an instance whose Dispose() method has already executed:
public void Execute()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Proceed with operational logic
}
According to the official .NET design guidelines,
Dispose() and DisposeAsync() must be idempotent. Calling Dispose() multiple times sequentially on the same object instance must complete safely without throwing an exception or re-executing cleanup logic.
Classes inheriting from
CriticalFinalizerObject guarantee that their finalizer code is pre-compiled (JITted) at object construction time. Even in extreme runtime conditions (such as low-memory stack overflows or thread interruptions), the CLR guarantees their finalizers will execute. SafeHandle is built on this construct.
Implement both interfaces so callers can consume the object with synchronous
using or asynchronous await using. Coordinate cleanup by directing both paths to an asynchronous or synchronous core disposal method:
public async ValueTask DisposeAsync()
{
await DisposeAsyncCore();
Dispose(disposing: false);
GC.SuppressFinalize(this);
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
When an unsuppressed finalized object becomes dead, the GC cannot immediately free its memory:
- The object reference is moved from the Finalization Queue to the F-Reachable Queue.
- Because the F-Reachable queue acts as an active root, the object is promoted to the next generation (e.g., Gen 0 → Gen 1).
- The single runtime Finalizer thread eventually executes the finalizer method and clears the queue item.
- The memory is finally reclaimed on the subsequent collection of that promoted generation.
* Exception in Finalizer (
~ClassName): An unhandled exception on the finalizer thread will immediately terminate the entire application process (Crash/Fail-Fast) because the CLR finalizer loop cannot recover.* Exception in
Dispose(): If an exception occurs inside a finally block during disposal while an initial exception is already bubbling up the call stack, the original root exception can be swallowed and replaced by the disposal exception, hiding the root cause.
Yes,
struct instances can implement IDisposable.
Boxing Pitfall: If a struct is cast to an
IDisposable interface before calling Dispose(), it is boxed onto the managed heap. The disposal executes on the boxed copy, leaving the original struct fields unaffected:
MyStruct s = new MyStruct();
((IDisposable)s).Dispose(); // Boxed onto heap!
// Safe approach (No boxing in C#):
using (var safeStruct = new MyStruct()) { } // Emitted without boxing
Because
ref struct types (like Span<T>) cannot implement interfaces, they cannot implement IDisposable directly.
To allow
ref struct types to be used with the using statement, C# supports a pattern-based Dispose convention: any ref struct containing a public void Dispose() instance method can be used with using without implementing the interface.
In early .NET Framework classes (e.g.,
SqlConnection, StreamReader), Close() and Dispose() were both exposed. In almost all modern implementations, Close() simply calls Dispose() internally. Dispose() is the official standard contract that should be invoked.
The derived class should only override the
Dispose(bool disposing) method, clean up its own resources, and unconditionally call base.Dispose(disposing):
public class DerivedResource : BaseResource
{
private bool _derivedDisposed;
private SafeHandle _nativeHandle;
protected override void Dispose(bool disposing)
{
if (_derivedDisposed) return;
if (disposing)
{
// Free derived managed objects
}
// Free derived unmanaged handles
_nativeHandle?.Dispose();
_derivedDisposed = true;
// Call base class disposal
base.Dispose(disposing);
}
}
Task vs ValueTask<T> & Asynchronous Internals
*
Task<T>: A reference type (class) allocated on the managed GC heap. Every invocation of an async method returning Task<T> that produces a distinct result allocates a new object on the heap.*
ValueTask<T>: A discriminated union struct (value type) wrapping either a completed value T or a backing Task<T>/IValueTaskSource<T>. If the operation completes synchronously (e.g., in-memory cache hit), it incurs zero heap allocations.
Return
ValueTask<T> when both of the following conditions are met:
- The method is on a high-throughput hot path (e.g., socket reads, middleware processing, serialization, high-frequency caching).
- The method completes synchronously most of the time (> 90–95% of invocations).
public ValueTask<UserDto> GetUserAsync(int id)
{
// Fast path: synchronous cache hit (Zero allocation)
if (_memoryCache.TryGetValue(id, out UserDto user))
{
return ValueTask.FromResult(user);
}
// Slow path: asynchronous database fetch
return new ValueTask<UserDto>(FetchUserFromDbAsync(id));
}
Because
ValueTask instances may wrap pooled backing sources (IValueTaskSource), violating consumption rules causes undefined behavior, double-free bugs, or memory corruption.
The 3 Golden Rules:
- Never await a
ValueTaskmultiple times: The underlying source may have already been recycled back to a memory pool. - Never await a
ValueTaskconcurrently across multiple threads: The state machine expects a single synchronous consumer. - Never use
.Resultor.GetAwaiter().GetResult()before the operation completes: It will block or throw if the backing source is not completed.
valueTask.AsTask().IValueTaskSource<T> is an interface implemented by reusable objects to back a ValueTask<T> without allocating a new Task object when an operation completes asynchronously.
Instead of allocating a
TaskCompletionSource per async operation (such as reading from a network socket), the runtime or library (e.g., Socket or PipeReader) reuses a pooled object implementing IValueTaskSource. Once the caller awaits the ValueTask, the source is reset and returned to the pool.
For non-generic asynchronous methods (methods returning no payload):
Task.CompletedTaskis already a globally cached singleton reference type.- When a non-generic method completes synchronously, returning
Task.CompletedTaskincurs zero allocations. - A non-generic
ValueTaskstruct is larger in memory (2 words) than a reference pointer (1 word). Unless backed by a pooledIValueTaskSource, using non-genericTaskis usually simpler and equally efficient for synchronous completions.
If a method always executes asynchronously (completes on I/O), using
ValueTask<T> without IValueTaskSource is actually slower and uses more memory than Task<T> because:
- It still allocates the internal
Task<T>on the heap to manage asynchronous completion. - It wraps that allocated task inside a multi-word
ValueTask<T>struct, adding extra struct copying and stack-unwinding overhead.
When an
async method is compiled, the compiler transforms the method body into an IAsyncStateMachine struct:
- Local variables and parameters are captured as fields in the state machine struct.
- The method body is converted into a
MoveNext()method with a switch-case tracking execution states. - If an awaited operation completes synchronously,
MoveNext()continues executing immediately without pausing. - If the operation is pending, the state machine struct is boxed to the heap, and its
MoveNextdelegate is registered as a callback on the awaiter.
*
Task.CompletedTask: Returns a cached, successfully completed singleton Task instance.*
Task.FromResult<T>(value): Returns a completed Task<T> holding the result. (The runtime caches common values like false, true, 0, and default to avoid allocations).*
Task.FromException<T>(exception): Returns a faulted Task<T> holding the specified exception without having to throw and unwind the execution stack synchronously.
*
Task.WhenAll: Asynchronously waits for all tasks to complete. If multiple tasks fault, awaiting WhenAll re-throws only the first exception encountered. To inspect all faults, inspect the whenAllTask.Exception.InnerExceptions collection.*
Task.WhenAny: Completes as soon as the first task finishes (whether succeeded, faulted, or canceled). It does not automatically observe exceptions on remaining tasks; remaining tasks must be awaited or handled to prevent unobserved task exceptions.
TaskCompletionSource<T> (TCS) represents the producer side of an unattached Task<T>, allowing external event-based or callback-based APIs to be exposed as awaitable tasks:
public Task<string> DownloadLegacyAsync(string url)
{
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var client = new LegacyClient();
client.OnComplete += (data) => tcs.TrySetResult(data);
client.OnError += (err) => tcs.TrySetException(new Exception(err));
client.Start(url);
return tcs.Task;
}
By default, calling
tcs.SetResult() invokes the task's continuation synchronously on the calling thread.
If the continuation performs heavy synchronous work or acquires a lock that the calling thread is waiting on, it causes deadlocks and thread-pool starvation. Specifying
TaskCreationOptions.RunContinuationsAsynchronously forces all downstream continuations to be queued to the ThreadPool asynchronously.
AsyncLocal<T> stores ambient data that automatically flows down the asynchronous execution path across await points and thread hops via the ExecutionContext.
Values flow downwards to sub-tasks and child calls, but mutations in child tasks do not flow backwards into parent callers (copy-on-write semantics). Used for correlation IDs, user contexts, and telemetry spans.
*
await Task.CompletedTask: Evaluates synchronously and immediately continues without relinquishing the thread or context.*
await Task.Yield(): Unconditionally yields execution back to the caller and posts the continuation to the ThreadPool or SynchronizationContext, allowing other pending work on the current thread to proceed.
An UnobservedTaskException occurs when a faulted
Task is garbage-collected without ever being awaited or having its Exception property inspected.
In modern .NET (default policy since .NET 4.5), unobserved exceptions trigger the
TaskScheduler.UnobservedTaskException event for logging, but they do not crash the process by default.
*
Task.Run(action): The recommended standard wrapper. Defaults to TaskCreationOptions.DenyChildAttach and automatically unwraps nested tasks if an async delegate is passed.*
Task.Factory.StartNew: Low-level API requiring explicit parameters. If an async delegate is passed, it returns a nested Task<Task> (requires .Unwrap()), and can lead to unexpected thread scheduling bugs if default parameters are misunderstood.
C#'s
await keyword does not require the object to be a Task. Any type that exposes a public method or extension method GetAwaiter() returning a type that implements INotifyCompletion (or ICriticalNotifyCompletion) can be awaited:
public struct SimpleAwaiter : INotifyCompletion
{
public bool IsCompleted => true;
public void GetResult() { }
public void OnCompleted(Action continuation) => continuation();
}
public class CustomJob
{
public SimpleAwaiter GetAwaiter() => new SimpleAwaiter();
}
// Usage:
await new CustomJob(); // Valid C#!
ThreadPool, Synchronization Context & Async Concurrency
The .NET ThreadPool manages a dynamically sized pool of worker and I/O completion threads to execute short-lived units of work without thread creation overhead.
Queue Architecture:
- Global Queue: Single FIFO queue where work items posted from non-ThreadPool threads or via
ThreadPool.QueueUserWorkItemarrive. - Local Queues (Per-Thread): Each ThreadPool worker thread has its own dedicated LIFO/FIFO work-stealing queue. Work queued from within an existing ThreadPool thread goes to its local queue to maximize CPU L1/L2 cache locality.
- Work-Stealing Algorithm: When a worker thread's local queue is empty, it steals work from the tail of another worker thread's local queue (FIFO order) to balance processing loads across CPU cores.
ThreadPool Starvation occurs when all available ThreadPool worker threads are blocked waiting for other operations, leaving no free threads to process incoming work items or task continuations.
Primary Cause: "Sync-Over-Async" anti-patterns (e.g., calling
.Result, .Wait(), or .GetAwaiter().GetResult() on asynchronous methods). The calling thread blocks synchronously, waiting for a continuation that requires another ThreadPool thread to execute.
The Injection Bottleneck: When starved, the ThreadPool uses a Hill-Climbing algorithm to inject new worker threads slowly (typically throttled to ~1–2 new threads per second), resulting in severe request timeouts and catastrophic latency spikes under traffic load.
In legacy ASP.NET (.NET Framework), an
AspNetSynchronizationContext ensured that continuations returned to the original request thread and preserved HttpContext.Current thread-local storage.
Why it was removed in ASP.NET Core:
- Performance: Eliminates the CPU overhead of capturing contexts and scheduling cross-thread postbacks. Continuations resume on whichever ThreadPool thread becomes available first.
- Deadlock Elimination: Removes the classic single-threaded synchronization deadlock where a blocked UI/ASP.NET thread prevented the async continuation from completing on the same captured context.
- Modern Context Flow:
HttpContextis passed explicitly or flowed viaIHttpContextAccessorusingAsyncLocal<T>instead of thread-affinity affinity pools.
ConfigureAwait(false) instructs the awaiter not to capture the current SynchronizationContext or TaskScheduler for executing the continuation.
* In ASP.NET Core Application Code: Because ASP.NET Core has no
SynchronizationContext, ConfigureAwait(false) is a no-op (has no effect) inside controller actions or minimal API routes.* In Reusable Class Libraries / NuGet Packages: It remains a best practice to use
ConfigureAwait(false) because your library might be consumed by UI applications (WPF, MAUI, WinForms) where a SynchronizationContext exists.
*
ExecutionContext (Ambient State): Represents the environmental logical context (security identity, claims principal, distributed telemetry traces, AsyncLocal<T> values). It always flows automatically across asynchronous points and thread boundaries unless explicitly suppressed via ExecutionContext.SuppressFlow().*
SynchronizationContext (Location/Scheduling): Represents where and how a delegate is executed (e.g., dispatching work back to a specific UI thread in WPF/WinForms).
The C#
lock statement relies on Monitor.Enter / Monitor.Exit, which requires thread affinity (the releasing thread must be the acquiring thread). Because await may resume on a different ThreadPool thread, putting await inside a lock statement causes a compile-time error.
Safe Async Synchronization Alternatives:
SemaphoreSlim(1, 1): Standard async-compatible mutual exclusion lock usingawait semaphore.WaitAsync().- Lock-free Constructs:
Interlocked.Increment,Interlocked.CompareExchange, orConcurrentDictionaryfor fine-grained state updates. - Channels / Actor Models: Single-reader processing via
System.Threading.Channelsto eliminate synchronization locks entirely.
private static readonly SemaphoreSlim _mutex = new(1, 1);
public async Task SafeAsyncOperation()
{
await _mutex.WaitAsync();
try {
await PerformDatabaseUpdateAsync();
}
finally {
_mutex.Release();
}
}
* Worker Threads: General-purpose CPU threads that execute application tasks, background jobs, timer callbacks, and async continuations.
* I/O Completion (IOCP) Threads: Specialized OS-level threads bound to native asynchronous I/O completion handles (Windows IOCP / Linux epoll/io_uring). When non-blocking hardware I/O (network packets, disk reads) finishes, the OS signals the IOCP thread to wake up and post the async completion continuation without holding a thread during waiting.
Methods declared with
async void should only be used for UI or event handlers.
Dangers in Backend / Web Code:
- Cannot be Awaited: Callers have no
Taskhandle to await completion, resulting in untracked "fire-and-forget" execution. - Process-Crashing Exceptions: Any unhandled exception thrown inside an
async voidmethod is rethrown directly onto the ambientSynchronizationContextor ThreadPool root, crashing the entire process immediately. - Impossible to Unit Test: Testing frameworks cannot determine when an
async voidmethod has finished running.
Cancellation in .NET is cooperative (meaning threads are never abruptly aborted or killed).
Workflow:
- A
CancellationTokenSource(CTS) creates a token passed down through async method signatures. - The operation periodically inspects
token.IsCancellationRequestedor callstoken.ThrowIfCancellationRequested(). - When
cts.Cancel()is triggered, the operation throws anOperationCanceledException(orTaskCanceledException), unwinding the execution stack and releasing resources viausing/finallyblocks safely.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // Timeout cancel
await httpClient.GetAsync("https://api.example.com", cts.Token);
ThreadPool.SetMinThreads(workerThreads, completionPortThreads) sets the minimum number of worker threads the ThreadPool maintains immediately available before throttling thread creation to the 1–2 threads/sec rate.
When to adjust: In high-load services that experience bursty, high-concurrency spikes at startup (e.g., hundreds of concurrent incoming requests) or legacy apps with unavoidable synchronous third-party dependencies, raising
MinThreads (e.g., to 200–500) prevents latency spikes during initial bursts.
Starting in .NET 6, the core ThreadPool implementation was rewritten from native C++ runtime code into managed C#.
Benefits:
- Cross-platform uniformity across Windows, Linux, macOS, and WebAssembly.
- Easier diagnostic instrumentation and custom profiling.
- Support for modern low-level primitives like Linux
io_uringwithout runtime engine changes.
*
Interlocked (Atomic CPU Instructions): Uses atomic processor-level instructions (e.g., CMPXCHG on x86/x64). Executes in nanoseconds without kernel transitions, context switching, or thread suspension. Ideal for counters, flags, and lock-free state swaps (e.g., Interlocked.Increment(ref _counter)).*
lock / Monitor / SemaphoreSlim: Heavyweight constructs that suspend threads, maintain wait queues, and acquire synchronization barriers when contention occurs.
*
Task.WhenAll: Starts all tasks simultaneously. If iterating over 10,000 items, it spawns 10,000 concurrent tasks at once, risking socket exhaustion, memory spikes, or database connection pool depletion.*
Parallel.ForEachAsync (.NET 6+): Enforces controlled concurrency with built-in throttling via MaxDegreeOfParallelism:
await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = 10 },
async (url, ct) => {
await DownloadAsync(url, ct);
});
*
[ThreadStatic]: Gives each physical OS thread its own isolated variable. In asynchronous code, this causes severe data-corruption bugs because an await continuation may resume on a completely different physical thread where the value is missing or stale.*
AsyncLocal<T>: Associates state with the logical asynchronous execution flow across all thread transitions and continuations. Safe and recommended for async code.
Run
dotnet-counters monitor System.Runtime -p <PID> and inspect the following metrics:
threadpool-thread-count: Total active worker threads in the pool.threadpool-queue-length: Number of work items queued waiting for a free thread (a growing number indicates starvation).threadpool-completed-items-count: Throughput rate of completed work items per second.
PeriodicTimer replaces legacy timer callbacks (like System.Threading.Timer) by providing an async-friendly polling loop without overlapping ticks:
public class HeartbeatService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
// Waits asynchronously for the next tick without thread blocking or overlaps
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await SendHeartbeatAsync(stoppingToken);
}
}
}
System.Threading.Channels & Async Queues
System.Threading.Channels is a high-performance, low-allocation, async-first Producer-Consumer queue library.
It provides a thread-safe communication pipe between producers (writing data) and consumers (reading data) without holding threads in blocking wait states, designed natively around
ValueTask, IAsyncEnumerable<T>, and non-blocking backpressure.
*
BlockingCollection<T>: Synchronous-only. Producers and consumers block physical OS threads using Thread.Sleep or Monitor.Wait when the collection is full or empty, degrading thread pool scalability under high async load.*
BufferBlock<T> (TPL Dataflow): Async-capable and feature-rich, but carries heavy allocation overhead and complex scheduling state machines.*
Channel<T>: Highly optimized for low memory allocations, fully asynchronous, and designed specifically for lightweight in-process message passing.
* Unbounded Channel (
Channel.CreateUnbounded<T>()): Has no capacity limit. Writers never wait and can push infinite items.Risk: If producers publish data faster than consumers can process it (producer-consumer mismatch), the queue grows unbounded in memory, eventually causing an
OutOfMemoryException (OOM) crash.* Bounded Channel (
Channel.CreateBounded<T>(capacity)): Enforces a strict maximum buffer size. When the channel reaches capacity, writers must asynchronously wait or follow a defined drop strategy (Backpressure).
When a bounded channel reaches its capacity,
BoundedChannelFullMode determines the behavior:
Wait(Default):WriteAsync()asynchronously pauses the producer without blocking threads until space becomes available. (Standard Backpressure).DropOldest: Drops the oldest item in the queue to make room for the new incoming item. Ideal for real-time telemetry or latest-state metrics where stale data is obsolete.DropNewest: Discards the newest incoming item being written.DropWrite: Drops the item currently being written without adding it to the queue.
Create a shared singleton queue service and consume it via
IAsyncEnumerable in a background worker:
// Producer queue wrapper
public class BackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
Channel.CreateBounded<Func<CancellationToken, ValueTask>>(new BoundedChannelOptions(500) {
FullMode = BoundedChannelFullMode.Wait
});
public async ValueTask EnqueueAsync(Func<CancellationToken, ValueTask> workItem) =>
await _queue.Writer.WriteAsync(workItem);
public ChannelReader<Func<CancellationToken, ValueTask>> Reader => _queue.Reader;
}
// Consumer Background Service
public class QueueConsumer(BackgroundTaskQueue queue) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// ReadAllAsync provides clean async streaming
await foreach (var workItem in queue.Reader.ReadAllAsync(stoppingToken))
{
await workItem(stoppingToken);
}
}
}
When creating a channel, you can configure:
var options = new BoundedChannelOptions(1000)
{
SingleWriter = true,
SingleReader = true
};
* SingleWriter = true: Guarantees only one thread/task writes at a time. The channel bypasses internal interlocked synchronization and write locks.*
SingleReader = true: Guarantees only one consumer reads at a time. It uses optimized lock-free ring-buffer state machines, significantly reducing synchronization latency and CPU cache invalidations.
The producer signals that no more items will ever be written by calling
channel.Writer.Complete() (or passing an exception via channel.Writer.Complete(exception)):
- Subsequent calls to
WriteAsync()will throw aChannelClosedException. - Consumers reading via
ReadAllAsync()will continue draining all remaining buffered items before the loop finishes naturally. - Consumers waiting on
WaitToReadAsync()receivefalseonce the queue is fully drained.
By default, when a reader completes an await on
ReadAsync(), its continuation is queued to the ThreadPool asynchronously.
If
AllowSynchronousContinuations = true is set:
- The writer thread that pushes an item will synchronously execute the consumer's continuation code on the producer's thread.
- Danger: If the consumer performs heavy processing or acquires locks held by the writer, it causes deadlocks, thread-hijacking, and stack overflows. Only enable this in micro-benchmarked, non-blocking hot paths.
*
ReadAllAsync(): Clean, standard await foreach syntax. Best for standard item-by-item processing.*
WaitToReadAsync() + TryRead(): Best for batching operations (e.g., draining up to 100 items at once to perform a bulk database insert):
while (await reader.WaitToReadAsync(stoppingToken))
{
var batch = new List<LogMessage>();
while (batch.Count < 100 && reader.TryRead(out var item))
{
batch.Add(item);
}
await SaveBatchToDatabaseAsync(batch, stoppingToken);
}
Spawn multiple background worker tasks sharing the same
ChannelReader<T> instance (ensuring SingleReader = false):
public async Task StartConsumersAsync(ChannelReader<WorkItem> reader, int workerCount, CancellationToken ct)
{
var workers = Enumerable.Range(0, workerCount)
.Select(workerId => Task.Run(async () => {
await foreach (var item in reader.ReadAllAsync(ct))
{
await ProcessItemAsync(item, workerId);
}
}));
await Task.WhenAll(workers);
}
Backpressure is the mechanism that signals the data producer to slow down when the downstream consumer cannot keep pace.
By using a Bounded Channel (e.g., capacity 1,000) with
BoundedChannelFullMode.Wait, the HTTP controller endpoint awaiting channel.Writer.WriteAsync(data, ct) is asynchronously suspended. This slows down incoming HTTP request acceptance naturally, preventing massive memory accumulation during traffic spikes.
A single
Channel<T> provides point-to-point delivery (each message is consumed by exactly one worker).
To implement a Broadcast (Pub-Sub) mechanism where every subscriber gets every message:
- Maintain a thread-safe list of active subscriber channels (
ConcurrentDictionary<Guid, Channel<T>>). - When a publisher publishes an event, it iterates over all registered subscriber channels and calls
WriteAsync()on each. - When a subscriber disconnects, its dedicated channel is completed and removed from the dictionary.
*
TryWrite(item): Synchronous and non-blocking. Returns true if the item was immediately added; returns false if the channel is full or closed. Zero allocation and instant execution.*
WriteAsync(item): Asynchronous. If the channel is bounded and currently full, it returns a pending ValueTask that completes when buffer space becomes available.
If a producer crashes during message streaming, it can pass the caught fault to the writer's completion method:
try {
await GenerateEventsAsync(channel.Writer);
channel.Writer.Complete(); // Normal completion
}
catch (Exception ex) {
channel.Writer.Complete(ex); // Faulted completion
}
When the consumer iterates through ReadAllAsync() or calls ReadAsync(), the original producer exception is rethrown directly on the consumer side.
* Zero Per-Item Allocations: Unbounded channels use linked node buffers, while bounded channels utilize contiguous circular arrays (ring buffers) that recycle slot references internally.
* ValueTask Optimization:
ReadAsync and WriteAsync return ValueTask/ValueTask<bool>, ensuring zero heap allocations on fast synchronous paths.* Lock-free Synchronization: Avoids kernel transition objects (e.g.,
AutoResetEvent) by managing state transitions via atomic Interlocked flags.
Channels do not have built-in priority queue sorting. To implement message prioritization:
- Create two channels:
_highPriorityChanneland_normalPriorityChannel. - In the consumer loop, always poll the high-priority reader first using
TryRead()before falling back to reading from the normal channel:
public async ValueTask<WorkItem> DequeueNextAsync(CancellationToken ct)
{
// Fast check: drain high priority first
if (_highPriorityChannel.Reader.TryRead(out var highItem))
return highItem;
// Await whichever has data next (prioritizing high)
var readHigh = _highPriorityChannel.Reader.WaitToReadAsync(ct).AsTask();
var readNormal = _normalPriorityChannel.Reader.WaitToReadAsync(ct).AsTask();
await Task.WhenAny(readHigh, readNormal);
if (_highPriorityChannel.Reader.TryRead(out var item))
return item;
return await _normalPriorityChannel.Reader.ReadAsync(ct);
}
EF Core Query Optimization & Data Performance
The N+1 Problem occurs when an initial query fetches $1$ parent record set, and subsequent navigation property accesses (often inside loops or serializers via lazy loading) trigger $N$ separate round-trip database queries for each child record.
Root Solutions:
- Eager Loading: Use
.Include()and.ThenInclude()to fetch related records in the initial query via SQLJOINstatements. - Explicit Projection: Use
.Select()to flatten parent and child properties directly into a DTO in a single round-trip. - Disable Lazy Loading: Avoid virtual navigation properties with lazy loading proxies enabled in high-throughput API endpoints.
Cartesian Explosion occurs when eager loading multiple 1-to-many collection navigations (e.g.,
Orders.Include(o => o.Items).Include(o => o.Logs)) generates a single massive SQL query with multiple LEFT JOIN clauses.
The result set returns a cross-product matrix (Cartesian product) where parent data is duplicated across every joined row, causing massive network bandwidth consumption and high memory materialization overhead.
The Fix (
.AsSplitQuery()):
var orders = await context.Orders
.Include(o => o.Items)
.Include(o => o.Logs)
.AsSplitQuery() // Generates 3 separate, clean SQL statements
.ToListAsync();
EF Core executes 1 query for Orders, 1 for Items, and 1 for Logs, combining them in memory without Cartesian row duplication.
Querying full entity models retrieves every column in the table (including large text, blobs, and tracking metadata) and attaches them to the
ChangeTracker.
Benefits of
.Select() Projection:
- Generates Targeted SQL: EF Core translates projections into
SELECT col1, col2instead ofSELECT *. - Bypasses ChangeTracker: Projections directly to DTOs/records are automatically untracked, saving memory snapshot allocations.
- Optimizes Indexes: Allows database query planners to leverage non-clustered Covering Indexes, avoiding clustered table lookups.
var users = await context.Users
.Where(u => u.IsActive)
.Select(u => new UserSummaryDto(u.Id, u.FullName, u.Email))
.ToListAsync();
* Server-Side Translation: LINQ expressions (
Where, OrderBy, GroupBy) are parsed as Expression Trees and translated into native SQL executed on the database server engine.* Client-Side Evaluation: Operations that cannot be converted to SQL (e.g., custom C# methods, regex, unmapped string methods) must be computed in memory on the application server.
Modern Behavior: In modern EF Core (3.0+), EF Core throws an exception at runtime if a top-level LINQ filter cannot be translated to SQL, preventing accidental client-side pulling of entire tables. Client evaluation is only allowed on the final
.Select() projection.
Translating a LINQ Expression Tree to a relational SQL command is computationally expensive.
Query Caching Mechanism:
- EF Core inspects the shape of the LINQ query and extracts parameterized values into parameters (e.g.,
@__id_0). - It generates a structural cache key and checks the Compiled Query Cache.
- If a cache hit occurs, EF Core reuses the pre-compiled SQL string and execution delegate directly, bypassing the expensive query parsing and SQL translation pipeline.
If dynamic LINQ queries or raw SQL string interpolations embed hard-coded constant values directly into the query expression rather than using variables:
// BAD: Each query creates a new cache entry!
context.Orders.Where(o => o.Id == 105);
context.Orders.Where(o => o.Id == 106);
// GOOD: Parametrized shape is cached once
int orderId = 105;
context.Orders.Where(o => o.Id == orderId); // Parameterized as @__orderId_0
Embedding constants forces EF Core to re-parse and compile a brand new SQL template for every unique value, bloating the internal query cache memory and degrading CPU throughput.
EF.CompileAsyncQuery() pre-compiles a LINQ query into an immutable, static delegate at application startup, eliminating query cache key generation and dictionary lookup costs on critical hot paths:
private static readonly Func<AppDbContext, int, Task<User?>> GetUserByIdCompiled =
EF.CompileAsyncQuery((AppDbContext db, int id) =>
db.Users.AsNoTracking().FirstOrDefault(u => u.Id == id));
// High-frequency hot execution:
var user = await GetUserByIdCompiled(_dbContext, userId);
Before EF Core 7, updating or deleting 1,000 records required loading all 1,000 entities into memory, attaching them to the
ChangeTracker, modifying properties, and calling SaveChangesAsync() (generating 1,000 individual SQL statements).
ExecuteUpdateAsync & ExecuteDeleteAsync (.NET 7+):
// Executes a single atomic SQL UPDATE/DELETE statement directly on the DB server
await context.Orders
.Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoffDate)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, OrderStatus.Cancelled)
.SetProperty(o => o.UpdatedAt, DateTime.UtcNow));
Executes in a single database round-trip without loading entities into memory or touching the ChangeTracker.
Instantiating a new
DbContext instance per HTTP request incurs internal allocation costs (initializing internal service providers, change trackers, and state objects).
builder.Services.AddDbContextPool<AppDbContext>() maintains a high-performance object pool of reusable DbContext instances:
- When a request completes, the context state is reset and returned to the pool.
- Eliminates allocation overhead on high-throughput services.
- Constraint: Pooled
DbContextconstructors cannot accept custom per-request scoped services; all dependencies must be resolved via standard configurations.
*
string.Contains(text): Translates to SQL LIKE '%' + @text + '%', but automatically escapes wildcard characters (%, _, [) to match exact C# string containment semantics.*
EF.Functions.Like(column, pattern): Passes the raw pattern directly to the underlying SQL engine LIKE operator, enabling native database wildcard matching without client-side parameter escaping.
*
context.Users.Count() > 0: Generates SELECT COUNT(*) FROM Users, forcing the database engine to count all matching rows across the entire index or table scan.*
context.Users.Any(): Generates SELECT CASE WHEN EXISTS (SELECT 1 FROM Users) THEN 1 ELSE 0 END. The database terminates the search immediately upon encountering the first matching record, completing in $O(1)$ time.
* Offset Pagination (
.Skip(10000).Take(20)): Translates to OFFSET 10000 ROWS FETCH NEXT 20 ROWS. The database must scan, sort, and discard the first 10,000 rows before returning 20, degrading exponentially on deep pages.* Keyset Pagination (Seek): Queries records after the last seen key from the previous page:
// Constant O(1) performance using indexed columns regardless of page depth
var page = await context.Orders
.OrderBy(o => o.Id)
.Where(o => o.Id > lastSeenOrderId)
.Take(20)
.ToListAsync();
.TagWith("Tag description") injects a comment at the top of the generated SQL statement sent to the database:
var activeUsers = await context.Users
.TagWith("ActiveUsersReport: DashboardController.GetStats")
.Where(u => u.IsActive)
.ToListAsync();
When DBA profiling tools (SQL Server Profiler, pg_stat_statements, Dynatrace, Datadog) flag slow queries, developers can instantly identify the exact C# line and endpoint that generated the query.
Global Query Filters (e.g.,
modelBuilder.Entity<Order>().HasQueryFilter(o => !o.IsDeleted)) automatically append a WHERE IsDeleted = 0 condition to every query executed against that entity.
Performance Risk: If database indexes do not include the filtered column (e.g.,
IsDeleted), queries may skip optimal non-clustered indexes and perform costly table scans.
context.Orders.IgnoreQueryFilters().ToListAsync().*
FromSqlInterpolated (or FromSql$"..."): Safely wraps interpolated C# variables into parameterized DbParameter objects (e.g., @p0), completely preventing SQL Injection attacks.*
FromSqlRaw: Executes raw SQL strings as-is. If strings are concatenated using standard C# string formatting (e.g., $"SELECT * FROM Users WHERE Name = '{name}'"), it opens severe SQL Injection vulnerabilities. Always pass explicit parameters when using FromSqlRaw.
Connection resiliency automatically retries failed database commands caused by transient network glitches or cloud failovers:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
}));
BeginTransactionAsync) with an execution strategy, you must wrap transaction logic inside executionStrategy.ExecuteAsync(...) to allow retrying the entire transaction block safely.EF Core ChangeTracker, Entity States & Concurrency
The
ChangeTracker tracks entity instances and detects modifications using two primary mechanisms:
- Snapshot Change Tracking (Default): When an entity is queried, EF Core stores a snapshot copy of its original property values in memory. When
SaveChangesAsync()is called, it callsDetectChanges(), comparing current values against the snapshot to generate minimal SQLUPDATEstatements. - Notification Entities: Entities implementing
INotifyPropertyChangedorINotifyPropertyChangingdirectly notify the tracker when properties change, skipping the snapshot comparison scan.
*
Detached: Not tracked by the DbContext. No SQL generated.*
Unchanged: Tracked, matches database state. No SQL generated on SaveChangesAsync().*
Added: Tracked as new. Generates an INSERT statement, and database-generated keys are populated back onto the entity.*
Modified: Tracked, one or more properties changed. Generates an UPDATE statement updating modified columns.*
Deleted: Tracked and marked for removal. Generates a DELETE statement.
*
AsNoTracking(): Completely bypasses the ChangeTracker and identity resolution. If a query returns multiple rows referencing the same parent entity (e.g., via Include), EF Core creates multiple distinct C# object instances in memory with identical IDs. Maximum speed for read-only queries.*
AsNoTrackingWithIdentityResolution(): Bypasses change tracking and snapshot creation, but maintains a temporary identity map during query execution. It ensures that duplicate rows for the same entity ID map to a single shared object reference in the returned graph without the full memory overhead of standard tracking.
Optimistic Concurrency assumes record conflicts are rare. Instead of placing database locks on rows during reads, it verifies that the record hasn't changed since it was loaded when updating.
Configure a concurrency token via data annotation or Fluent API:
// Using Timestamp / RowVersion in SQL Server:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = [];
}
// Fluent API:
modelBuilder.Entity<Product>()
.Property(p => p.RowVersion)
.IsRowVersion();
When updating, EF Core generates: UPDATE Products SET Price = @p0 WHERE Id = @p1 AND RowVersion = @originalRowVersion. If another process modified the row, $0$ rows are affected, throwing a DbUpdateConcurrencyException.
Catch the exception and inspect the
PropertyValues to resolve the conflict:
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync();
if (databaseValues == null)
{
// Entity was deleted by another user
throw new Exception("Record has been deleted.");
}
// ClientWins: Overwrite database with client values
entry.OriginalValues.SetValues(databaseValues);
await context.SaveChangesAsync();
// OR DatabaseWins: Discard client changes and refresh from DB
// entry.Reload();
}
*
context.Attach(entity): Sets the root entity and its reachable graph to Unchanged. No SQL is executed unless properties are subsequently modified.*
context.Update(entity): Recursively marks the root entity and every entity in its navigation graph as Modified. On save, EF Core sends an UPDATE statement updating every single column in the table, even unchanged columns.*
context.Entry(entity).Property(p => p.Name).IsModified = true: Marks only that specific property as dirty, emitting a targeted UPDATE Table SET Name = @val WHERE Id = @id.
Pessimistic locking places physical database locks on rows at read time to block other concurrent transactions until the lock is released.
EF Core does not have built-in pessimistic LINQ keywords, so you execute raw SQL or transactional locking hints:
// SQL Server: UPDLOCK / ROWLOCK inside a transaction
using var transaction = await context.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead);
var product = await context.Products
.FromSqlRaw("SELECT * FROM Products WITH (UPDLOCK, ROWLOCK) WHERE Id = {0}", productId)
.FirstOrDefaultAsync();
product.Stock -= 1;
await context.SaveChangesAsync();
await transaction.CommitAsync();
Cause: Attempting to attach or track an entity instance when the
DbContext is already tracking another distinct object instance with the exact same primary key.Fixes:
- Use
AsNoTracking()on queries when loading entities for read-only or detached modification flows. - Locate the tracked entity via
context.Set<T>().Local.FindEntry(key)and update its values usingcontext.Entry(tracked).CurrentValues.SetValues(detachedEntity). - Ensure proper per-request
Scopedlifetime forDbContextinstances to prevent cross-request entity leakage.
By default, EF Core automatically triggers
ChangeTracker.DetectChanges() on calls to Add, Attach, Find, SaveChanges, and Entries.
When executing large batch operations (e.g., adding 5,000 entities in a loop), calling
DetectChanges() inside every iteration degrades performance to $O(N^2)$ algorithmic complexity.
context.ChangeTracker.AutoDetectChangesEnabled = false;
try {
foreach (var item in largeCollection) {
context.Orders.Add(item);
}
}
finally {
context.ChangeTracker.AutoDetectChangesEnabled = true;
}
await context.SaveChangesAsync(); // Calls DetectChanges() once at the end
context.ChangeTracker.Clear() instantly resets the change tracker, detaching every tracked entity without having to dispose and re-instantiate the DbContext.
Use Case: Long-running background batch jobs or unit-of-work loops where thousands of records are processed in chunks. Clearing the tracker after saving each batch prevents memory accumulation and avoids tracking collisions on subsequent batches.
Shadow Properties are properties defined in the EF Core data model that do not exist as fields or properties on the C# entity class (e.g.,
LastModified, TenantId, or unexposed foreign keys).
They are stored and tracked entirely inside the
ChangeTracker snapshot dictionaries:
// Definition in ModelBuilder:
modelBuilder.Entity<Order>().Property<DateTime>("LastUpdated");
// Accessing shadow values:
context.Entry(order).Property("LastUpdated").CurrentValue = DateTime.UtcNow;
Whenever
SaveChangesAsync() executes:
- If an explicit transaction (
BeginTransactionAsync) is already active, EF Core executes statements within that existing transaction. - If no transaction is open, EF Core automatically wraps all generated SQL statements in a single atomic database transaction.
- If any statement fails or a concurrency token mismatch occurs, EF Core rolls back the entire transaction and clears pending database state changes.
*
context.Users.Local: Returns a local LocalView<T> of entities currently tracked in memory by the DbContext in Added, Modified, or Unchanged states. It executes zero database queries.*
context.Users.ToList(): Generates and executes a SQL SELECT query against the remote database engine, merging new results into the tracker.
Implement a
SaveChangesInterceptor or override SaveChangesAsync to inspect modified entries before committing:
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct = default)
{
var context = eventData.Context;
if (context is null) return result;
var entries = context.ChangeTracker.Entries<IAuditableEntity>()
.Where(e => e.State is EntityState.Added or EntityState.Modified);
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
entry.Entity.CreatedAt = DateTime.UtcNow;
entry.Entity.UpdatedAt = DateTime.UtcNow;
}
return await base.SavingChangesAsync(eventData, result, ct);
}
*
PropertyEntry: Represents a scalar column value (e.g., context.Entry(user).Property(u => u.Email)). Allows checking IsModified, OriginalValue, and CurrentValue.*
ReferenceEntry: Represents a 1-to-1 navigation property (e.g., context.Entry(order).Reference(o => o.Customer)). Allows explicit loading via .LoadAsync().*
CollectionEntry: Represents a 1-to-many collection navigation (e.g., context.Entry(order).Collection(o => o.Items)). Allows querying related items directly using .Query() without loading all records into memory.
* Tracked Entities in Memory: If principal and dependent entities are both loaded in the
ChangeTracker, deleting the principal causes EF Core to apply client-side cascade delete rules (marking loaded children as Deleted or nullifying foreign keys).* Untracked Entities in Database: If child entities are not loaded into memory, EF Core relies entirely on the database foreign key
ON DELETE CASCADE constraint. If database-level cascading is disabled and children are untracked, a foreign key constraint violation exception is thrown.
IHttpClientFactory, Polly & Microservices Resilience
Even though
HttpClient implements IDisposable, wrapping it in a standard using block causes two major production problems:
- Socket Exhaustion: Disposing
HttpClientcloses the client, but the underlying TCP socket remains stuck in the operating system'sTIME_WAITstate (typically for 120–240 seconds). Under load, this depletes available network ports, throwingSocketException: Only one usage of each socket address is normally permitted. - DNS Staleness Bug (if kept as static singleton): Storing a single static
HttpClientfor the application's lifetime reuses TCP connections indefinitely, completely failing to detect remote DNS updates or IP failovers.
IHttpClientFactory decouples the lightweight HttpClient wrapper from the heavyweight underlying connection handler (HttpMessageHandler / SocketsHttpHandler):
- Handler Pooling: Handlers are pooled and reused across multiple
HttpClientinstances to prevent socket exhaustion. - Handler Lifetime Rotation: Each pooled handler has an active lifetime (default: 2 minutes). Once expired, it stops accepting new requests, drains in-flight requests, and gracefully disposes. A new handler is created, forcing a fresh DNS resolution while reusing sockets safely.
* 1. Basic / Direct Usage: Inject
IHttpClientFactory and call _factory.CreateClient().* 2. Named Clients: Register configured clients with a string key (e.g.,
services.AddHttpClient("GitHubClient", c => ...)) and resolve via _factory.CreateClient("GitHubClient").* 3. Typed Clients: Bind a concrete service class directly to an
HttpClient (e.g., services.AddHttpClient<IGitHubService, GitHubService>()) for strong typing.* 4. Generated Clients: Combine with tools like Refit to generate REST client implementations automatically from interfaces.
When you register a Typed Client via
builder.Services.AddHttpClient<OrderClient>(), the OrderClient class is registered in DI as Transient.
The Pitfall: If you inject
OrderClient into a Singleton service, that specific OrderClient (and its internal HttpClient instance) becomes captive and is never recreated. While the handler rotation pool still helps behind the scenes, you bypass the normal transient lifetime expectations.
DelegatingHandler is the client-side equivalent of ASP.NET Core middleware. It forms a pipeline of outbound HTTP message interceptors:
public class AuthHeaderHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token-xyz");
return await base.SendAsync(request, cancellationToken);
}
}
// Chaining in Program.cs:
builder.Services.AddTransient<AuthHeaderHandler>();
builder.Services.AddHttpClient<PaymentClient>()
.AddHttpMessageHandler<AuthHeaderHandler>();
The Circuit Breaker pattern prevents an application from repeatedly executing an operation that is guaranteed to fail, protecting failing downstream microservices from being overwhelmed.
States:
- Closed (Normal): Requests pass through. Failure counts/ratios are tracked.
- Open (Failing): If the failure threshold is exceeded, the circuit trips open. All incoming requests fail immediately (throwing a
BrokenCircuitException) without touching the network. - Half-Open (Testing Recovery): After a configured break duration, a limited number of trial requests are allowed through. If they succeed, the circuit resets to Closed; if any fail, it trips back to Open.
In .NET 8, Microsoft introduced native resilience packages built on Polly v8 (designed around low-allocation generic strategies):
- Replaces complex legacy policy syntax with clean
ResiliencePipelinebuilders. - Integrates first-class telemetry, logging, and metrics with OpenTelemetry.
- Provides a battle-tested preconfigured resilience stack via
AddStandardResilienceHandler().
AddStandardResilienceHandler() configures a standardized 5-layer resilience pipeline in the following execution order:
- Total Request Timeout: Caps the overall end-to-end execution time (including all retries).
- Standard Retry: Retries transient errors (HTTP 5xx, 408, 429) using exponential backoff with jitter.
- Circuit Breaker: Blocks calls if downstream consecutive failures exceed threshold rates.
- Attempt Timeout: Enforces a strict timeout on each individual HTTP attempt.
- Rate Limiter / Concurrency: Limits maximum simultaneous concurrent outbound requests.
builder.Services.AddHttpClient<OrderClient>()
.AddStandardResilienceHandler(options => {
options.Retry.MaxRetryAttempts = 3;
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
});
* Exponential Backoff: Multiplies the delay between retries exponentially (e.g., 2s, 4s, 8s, 16s) to give recovering downstream servers time to stabilize.
* Jitter (Random Noise): Adds random variance to retry intervals (e.g., 2s $\pm$ 300ms).
Why Jitter is critical: If a downstream service recovers from an outage, thousands of client instances retrying on identical fixed schedules will hit the service simultaneously, causing a catastrophic Thundering Herd (Retry Storm) that immediately crashes the server again.
Only Transient Faults (temporary errors likely to resolve quickly) should be retried:
- Retriable HTTP Codes:
408 Request Timeout,429 Too Many Requests,500 Internal Server Error,502 Bad Gateway,503 Service Unavailable,504 Gateway Timeout. - Retriable Exceptions:
HttpRequestException,TimeoutRejectedException,SocketException. - NEVER Retry:
400 Bad Request,401 Unauthorized,403 Forbidden,404 Not Found, or422 Unprocessable Entity(retrying client errors will always produce the same failure).
SocketsHttpHandler is the default high-performance, managed cross-platform HTTP transport implementation in modern .NET.
You can tune socket connection pooling, TCP keep-alives, and connection lifetimes directly:
builder.Services.AddHttpClient<CustomClient>()
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 50,
EnableMultipleHttp2Connections = true
});
The Fallback Strategy provides a substitute response or default value when all retry and circuit-breaker attempts fail:
var pipeline = new ResiliencePipelineBuilder<UserCatalog>()
.AddFallback(new FallbackStrategyOptions<UserCatalog>
{
FallbackAction = args => {
// Return cached static catalog or empty fallback response
return Outcome.FromResultAsValueTask(UserCatalog.EmptyFallback);
}
})
.AddTimeout(TimeSpan.FromSeconds(3))
.Build();
* Idempotent Methods (
GET, PUT, DELETE): Executing the request multiple times produces the same server state as a single execution. Safe to retry on transient network drops.* Non-Idempotent Methods (
POST, PATCH): If a POST /payments request times out, the server may have processed the transaction even though the response packet was lost. Retrying without an Idempotency-Key header will cause double-billing or duplicate entity creations.
Named after the compartmentalized partitions of a ship's hull, the Bulkhead pattern isolates resource pools (concurrency slots, threads) per downstream service.
If downstream Service A becomes unresponsive, its dedicated concurrent execution pool fills up, but it cannot consume threads or slots reserved for downstream Service B. This prevents a single failing dependency from causing a total cascading crash across the entire application.
In .NET 8+, register reusable named or generic resilience pipelines in the DI container using
AddResiliencePipeline:
builder.Services.AddResiliencePipeline("CustomDbPipeline", builder => {
builder
.AddRetry(new RetryStrategyOptions {
MaxRetryAttempts = 2,
Delay = TimeSpan.FromMilliseconds(500),
BackoffType = DelayBackoffType.Exponential
})
.AddTimeout(TimeSpan.FromSeconds(5));
});
// Consumption in a service:
public class OrderService(ResiliencePipelineProvider<string> pipelineProvider)
{
public async Task ProcessAsync()
{
var pipeline = pipelineProvider.GetPipeline("CustomDbPipeline");
await pipeline.ExecuteAsync(async ct => await CallExternalServiceAsync(ct));
}
}
In modern .NET,
HttpClient automatically propagates the W3C traceparent and tracestate HTTP headers using the ambient Activity (from System.Diagnostics):
- When an incoming request is received by ASP.NET Core, an
Activityis started with a uniqueTraceIdandSpanId. - Outbound requests made via
HttpClientautomatically read the activeActivity.Currentand injecttraceparent: 00-{TraceId}-{SpanId}-{Flags}into outbound headers. - This ensures seamless distributed tracing across microservices in tools like OpenTelemetry, Jaeger, and Application Insights without manual header manipulation.
Security, OAuth 2.0, OpenID Connect & JWT
* Authentication (AuthN): Verifies who you are (identity verification). Handled by authentication handlers validating cookies, tokens, or client certificates to construct the
ClaimsPrincipal.* Authorization (AuthZ): Verifies what you are allowed to do (permission verification). Evaluates policies, roles, scopes, and claims against the established
ClaimsPrincipal before allowing access to an endpoint or resource.
* OAuth 2.0: An authorization framework that defines protocols for client apps to obtain delegated access to APIs using an
access_token. It is not an authentication protocol.* OpenID Connect (OIDC): An identity authentication layer built directly on top of OAuth 2.0. It introduces the
id_token (a signed JWT) and a /userinfo endpoint to securely communicate user identity.* JWT (JSON Web Token): A compact, URL-safe token data format (RFC 7519) commonly used to represent access tokens and ID tokens.
A JWT consists of three Base64Url-encoded strings concatenated by dots (
Header.Payload.Signature):
- Header: Metadata specifying the token type (
typ: "JWT") and cryptographic signing algorithm (e.g.,alg: "RS256",kid: "key-id"). - Payload (Claims): Statements about the entity, including standard registered claims (
sub,iss,aud,exp,nbf) and custom application claims (roles, permissions). - Signature: Cryptographic hash generated by signing the encoded header and payload using a secret key (HMAC) or private key (RSA/ECDSA) to verify authenticity and prevent tampering.
Configure
JwtBearerHandler using TokenValidationParameters:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://identity.example.com"; // OIDC Identity Provider
options.Audience = "my-api-resource";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://identity.example.com",
ValidateAudience = true,
ValidAudience = "my-api-resource",
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30), // Prevent large drift tolerances
RequireSignedTokens = true,
ValidateIssuerSigningKey = true
};
});
* Symmetric (HS256): Uses a single shared secret key to both sign and validate tokens. Every API that validates tokens must know the secret. If one microservice is compromised, an attacker can forge valid tokens for all microservices.
* Asymmetric (RS256/ES256 - Recommended): Uses a private/public key pair. The centralized Authorization Server signs tokens with its Private Key, while resource APIs validate signatures using the publicly exposed Public Key (via JWKS endpoint). Resource APIs never have the ability to forge tokens.
JWKS is a JSON object exposing public cryptographic keys via an endpoint (e.g.,
/.well-known/jwks.json) on the Identity Provider.
When configured with an
Authority, the ASP.NET Core JwtBearerHandler:
- Automatically fetches and caches public keys from the provider's JWKS endpoint.
- Inspects the
kid(Key ID) header of incoming JWTs to match the valid public key. - Automatically refreshes cached keys when a token arrives signed by a newly rotated key, enabling zero-downtime key rotation without restarting APIs.
* Implicit Flow (Deprecated / Insecure): Returned access tokens directly in URL hash fragments, exposing them to browser histories, referrer leaks, and access token injection attacks.
* Authorization Code Flow with PKCE (Proof Key for Code Exchange): The client creates a secret
code_verifier and sends its cryptographic hash (code_challenge) with the initial authorization request. The authorization code returned is exchanged for tokens over a secure back-channel POST request by proving knowledge of the original verifier. This prevents authorization code interception attacks on public Single Page Apps (SPAs) and mobile clients.
Register policies in
Program.cs and enforce them via [Authorize(Policy = "...")]:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ElevatedAdmin", policy => policy
.RequireAuthenticatedUser()
.RequireRole("Administrator")
.RequireClaim("department", "Finance")
.RequireClaim("scope", "admin:write"));
});
// Applied to route or controller:
app.MapDelete("/accounts/{id}", DeleteAccount)
.RequireAuthorization("ElevatedAdmin");
Declarative policies (
[Authorize]) evaluate permissions before the action method executes and cannot inspect the specific data entity being modified.
Resource-Based Authorization evaluates permissions against a specific loaded domain resource:
public class DocumentAuthorizationHandler :
AuthorizationHandler<SameAuthorRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, SameAuthorRequirement req, Document doc)
{
if (context.User.FindFirstValue(ClaimTypes.NameIdentifier) == doc.AuthorId)
{
context.Succeed(req);
}
return Task.CompletedTask;
}
}
// Controller usage:
var authResult = await _authService.AuthorizeAsync(User, document, "EditDocumentPolicy");
if (!authResult.Succeeded) return Forbid();
Because JWTs are self-contained and stateless, an API validates tokens purely via signature and expiration time without calling a database.
Revocation Strategies:
- Short-lived Access Tokens (5–15 mins) + Refresh Tokens: Keep access token lifetimes brief so revoked permissions propagate quickly upon refresh token exchange.
- Distributed Blacklist (Redis): Store revoked token IDs (
jticlaim) in a high-speed in-memory Redis cache with TTL equal to the token's remaining lifetime. Middleware checks incomingjticlaims against Redis. - User Security Stamps: Embed a
security_stampclaim. When a user changes passwords or logs out, update their database stamp. Middleware checks token stamp validity via periodic cache lookup.
* ID Token (OIDC): Meant for the client application to read user profile details (name, email, subject ID). Never sent to resource APIs to authorize requests.
* Access Token (OAuth 2.0): Meant for the resource API to authorize requests. Contains scopes and audience; client applications treat it as an opaque bearer credential.
* Refresh Token: Long-lived credential used exclusively between the client and Authorization Server to obtain new access tokens when the current access token expires without prompting the user for credentials.
* Self-Contained JWT: Contains all claims inside the token itself. APIs validate it locally without network roundtrips. Drawbacks: Cannot be revoked immediately, and large token sizes increase HTTP header payloads.
* Reference Token (Opaque Token): A random GUID string. APIs must perform Token Introspection (RFC 7662) over HTTP back-channel to the Identity Server on every request (or cache responses locally). Benefit: Instant revocation at the central authorization server.
CSRF occurs when a malicious website tricks a victim's browser into sending unauthorized commands to a vulnerable application where the user is currently authenticated via automatic ambient credentials (Cookies).
Why JWT Bearer APIs are immune: Browsers do not automatically attach
Authorization: Bearer <token> headers on cross-site requests. The frontend JavaScript client must explicitly attach the header via script, preventing malicious cross-site form submissions from carrying the token.
* LocalStorage / SessionStorage: Accessible by any JavaScript executing in the origin context. Highly vulnerable to Cross-Site Scripting (XSS) attacks (malicious scripts can steal the token and exfiltrate it).
* HttpOnly, Secure, SameSite Cookies (BFF Pattern): Inaccessible to client-side JavaScript, completely neutralizing token theft via XSS. Best combined with Anti-Forgery tokens (CSRF protection) or the Backend-For-Frontend (BFF) architecture.
Avoid wildcard
AllowAnyOrigin() in production with authenticated APIs. Specify explicit allowed origins:
builder.Services.AddCors(options =>
{
options.AddPolicy("FrontendAppPolicy", policy =>
{
policy.WithOrigins("https://app.example.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.AllowCredentials(); // Required if sending cookies/auth headers
});
});
app.UseCors("FrontendAppPolicy");
The BFF Pattern removes tokens entirely from browser JavaScript storage:
- A lightweight server backend (e.g., ASP.NET Core YARP proxy or API Gateway) handles the OIDC login handshake and stores the access/refresh tokens in encrypted server-side session stores.
- The browser SPA only communicates with the BFF using secure,
SameSite=Strict,HttpOnlyauthentication session cookies. - When proxying requests to downstream microservices, the BFF strips the cookie, attaches the real
Authorization: Bearer <token>header, and forwards the call.
High Load, ThreadPool Starvation & Production Performance
Sync-over-Async occurs when synchronous code blocks on an asynchronous operation by calling
.Result, .Wait(), or .GetAwaiter().GetResult() instead of using await.
Why it crashes under high load:
- Incoming HTTP Request 1 arrives on ThreadPool Thread A and calls
GetDataAsync().Result. Thread A is now held in a blocked wait state. - The I/O operation finishes and queues its completion continuation to the ThreadPool.
- Under heavy concurrent traffic, hundreds of incoming requests simultaneously tie up all available ThreadPool worker threads in blocked wait states.
- The continuations needed to complete the tasks sit stalled in the ThreadPool queue with no free threads to process them, resulting in complete service gridlock and massive request timeouts.
The .NET ThreadPool uses a Hill-Climbing algorithm to dynamically adjust the number of active worker threads based on throughput metrics:
- When threads are actively processing CPU work, Hill-Climbing gradually adds or removes threads to find the optimal throughput sweet spot without incurring excessive context-switching costs.
- The Starvation Problem: When threads are blocked in sync-over-async waits, throughput drops to near zero. Hill-Climbing assumes adding threads may cause CPU contention, so it injects new threads at a heavily throttled rate (typically only 1 to 2 threads per second).
- If 500 requests arrive at once and block, it can take several minutes for the ThreadPool to spawn enough threads to clear the backlog, causing all inbound requests to time out (HTTP 504).
Attach
dotnet-counters to the running process without stopping the service:
dotnet-counters monitor --counters System.Runtime --process-id <PID>
Key Starvation Signatures:
ThreadPool Queue Length: Spiking into the hundreds or thousands (tasks are waiting for threads).ThreadPool Thread Count: Steadily climbing step-by-step (1–2 threads per second) rather than staying flat.CPU Usage: Often surprisingly low (e.g., 5–15%) despite extreme latency because threads are idling in wait states rather than executing instructions.
* ThreadPool Starvation: The CLR lacks available worker threads to execute CPU delegates or process asynchronous task continuations.
* Database Connection Pool Depletion: The ADO.NET connection pool (default max: 100 connections per connection string) runs out of available physical database connections.
Interconnection: When queries run slowly or leak connections (e.g., unclosed
DbCommand or long-running transactions), requests wait on SqlConnection.OpenAsync(). If callers use blocking sync calls (.Open()), database pool depletion immediately cascades into total ThreadPool Starvation.
1. Capture a full crash dump during the incident:
dotnet-dump collect -p <PID> --type Full
2. Analyze the dump file:
dotnet-dump analyze <dump_file_name>
3. Inspect all active managed thread call stacks using the SOS command:
> clrstack -all
4. Look for multiple threads sitting inside System.Threading.Monitor.Wait, ManualResetEventSlim.Wait, or Task.GetResultCore pointing to user code calling .Result or .Wait().
A Thread Convoy occurs when multiple concurrent threads bottleneck on a single shared mutual exclusion lock (
lock (_syncObject) or Monitor.Enter) protecting a slow operation (e.g., I/O or heavy computation).
As threads queue up waiting for the lock, CPU resources are wasted context-switching between threads waking up and failing to acquire the lock.
Detection: Monitor the
Monitor Lock Contention Rate / sec counter in dotnet-counters. A sustained high rate indicates lock contention that should be replaced with lock-free structures or fine-grained partitioned locks (e.g., ConcurrentDictionary).
A Cache Stampede occurs when a heavily requested cached item expires, and hundreds of concurrent incoming requests simultaneously detect a cache miss and hit the database to recalculate the same item at the same instant.
Prevention with
HybridCache (.NET 9+):
public class CatalogService(HybridCache cache, AppDbContext db)
{
public async Task<ProductDto> GetProductAsync(int id, CancellationToken ct)
{
return await cache.GetOrCreateAsync(
$"product-{id}",
async token => await db.Products.FindAsync([id], token),
cancellationToken: ct);
}
}
HybridCache implements built-in Request Coalescing (Stampede Lock): only the first request is allowed to execute the database factory delegate, while all other concurrent requests await that single in-flight task and share the result.
GC Thrashing happens when an application allocates high-frequency temporary objects faster than the ephemeral heaps (Gen 0/1) can handle, forcing frequent Gen 2 / Full Stop-The-World collections.
Mitigation Strategies:
- Reuse buffers via
ArrayPool<T>.SharedorMemoryPool<T>during JSON serialization and stream reads. - Use
Span<T>andReadOnlySpan<char>to parse payloads without string allocations. - Switch from
Task<T>toValueTask<T>on synchronous hot paths. - Ensure Server GC is enabled in
runtimeconfig.json("System.GC.Server": true).
Wrapping I/O calls in
Task.Run (e.g., await Task.Run(() => db.GetData())) inside a web API controller is an anti-pattern known as Async-over-Sync.
It does not make the underlying operation non-blocking; it simply offloads the synchronous block to a secondary ThreadPool thread. Under load, this consumes two ThreadPool threads for a single incoming HTTP request, doubling thread allocation overhead and accelerating ThreadPool Starvation.
When an application rapidly creates and disposes outgoing TCP connections (e.g., instantiating
new HttpClient() per request), ephemeral ports remain in the operating system's TIME_WAIT state for 2–4 minutes to ensure in-flight packets arrive cleanly.
Once the ~65,000 available outbound TCP ports are occupied, subsequent network calls throw:
System.Net.Sockets.SocketException: Only one usage of each socket address is normally permitted.
Fix: Use
IHttpClientFactory or long-lived SocketsHttpHandler instances with connection pooling.
* CPU-Bound: CPU utilization spikes to 90–100%. The bottleneck is computational (heavy JSON serialization, cryptographic hashing, un-indexed LINQ filtering). Profile using
dotnet-trace with Speedscope flame graphs to pinpoint hot CPU method instructions.* I/O-Bound: Latency is high, but CPU utilization is low (5–20%). The bottleneck is external waiting (slow database SQL queries, remote HTTP API calls, disk write stalls). Profile using distributed tracing (OpenTelemetry/Jaeger) to measure span durations.
1. Collect a CPU sampling trace from the production process:
dotnet-trace collect -p <PID> --providers Microsoft-DotNETCore-SampleProfiler --duration 00:00:30
2. Convert the trace to Speedscope format:
dotnet-trace convert <trace_file.nettrace> --format Speedscope
3. Open the output file in speedscope.app to analyze flame graphs, identify deep call-tree stacks, and detect runtime methods consuming the highest inclusive/exclusive CPU time.
Allocating objects $\ge$ 85,000 bytes places them directly on the Large Object Heap (LOH).
Because LOH memory is collected exclusively during Generation 2 full GC sweeps, high-frequency LOH allocations (e.g., repeatedly allocating
byte[100000] for image uploads or report exports) force continuous full Gen 2 collections. This triggers frequent Stop-The-World pauses that severely degrade request throughput under load.
If an endpoint processes a collection of 10,000 IDs by initiating 10,000 tasks and awaiting
Task.WhenAll(tasks):
- It attempts to open 10,000 concurrent database queries or HTTP calls simultaneously.
- This immediately exhausts connection pools, trips rate limiters, and floods the ThreadPool with thousands of continuation packets.
Parallel.ForEachAsync with a configured MaxDegreeOfParallelism (e.g., 10–20) to throttle concurrent execution.
* Concurrency Limiter (Load Shedding): Use ASP.NET Core's built-in
ConcurrencyLimiter middleware to cap active concurrent requests. Excess requests are queued or rejected immediately with 503 Service Unavailable before server memory is compromised.* Client Resilience (Polly): Wrap outbound HTTP calls in
AddStandardResilienceHandler(). When downstream dependencies fail, the circuit breaker trips open, failing calls fast (in microseconds) instead of keeping worker threads waiting on 30-second timeouts.
In containerized environments (Docker/Kubernetes), the .NET runtime respects cgroup limits:
- Heap Hard Limits: Ensure the container memory limit is passed properly; the GC calculates its total budget based on the cgroup limit (
GC.GetGCMemoryInfo().TotalAvailableMemoryBytes). - Server GC Core Scaling: On containers with fractional CPU limits (e.g.,
cpus: "0.5"), Server GC may create fewer heaps. If multi-threaded workloads experience contention, configureCOMPlus_GCHeapCountor enableDOTNET_GCName=clrgc.dllto control heap allocations explicitly.
Memory Leak Diagnostics, Heap Analysis & Dumps
In .NET, a managed memory leak is an unintended object retention problem. The Garbage Collector only reclaims memory for objects that are unreachable from GC roots.
If application code unintentionally maintains a reference path from an active root (e.g., a static collection, a long-running singleton, or a live event publisher) to objects that are no longer needed by business logic, the GC considers them alive and permanently promotes them into Generation 2, steadily increasing memory consumption until an
OutOfMemoryException occurs.
GC Roots are reference anchors from which the Garbage Collector begins its live object graph traversal during the marking phase.
Primary Types of GC Roots:
- Stack References: Local variables and parameters currently stored in CPU registers or call stack frames of active threads.
- Static References: Static fields and classes that remain alive for the lifetime of the
AppDomain. - GC Handles: Strong handles (
GCHandle) created by managed code or native interop to keep objects alive. - Finalization References: Unprocessed objects sitting in the internal F-Reachable queue waiting for finalizer execution.
When an object (subscriber) subscribes to an event on a longer-lived object (publisher) via
publisher.DataChanged += OnDataChanged:
- The publisher's internal multi-cast delegate retains a strong reference to the subscriber instance (via the
Targetpointer). - Even if the subscriber goes out of scope and is never used again, the publisher keeps it rooted in memory.
- Explicitly unsubscribe when done:
publisher.DataChanged -= OnDataChanged(e.g., insideDispose()). - Use weak event patterns (such as
WeakEventManager) where subscriptions do not hold strong references.
When a lambda or anonymous method references an outer variable or an instance member, the C# compiler generates a hidden display class (closure) on the managed heap:
public class ReportGenerator
{
private byte[] _largeReportPayload = new byte[10_000_000]; // 10 MB
public void ScheduleJob()
{
int jobId = 42;
// The closure captures 'this' along with 'jobId'!
_backgroundQueue.Enqueue(() => Process(jobId));
}
}
Even though the background job only needs jobId, the compiler-generated closure class holds a strong reference to this (and the 10 MB payload), keeping the entire ReportGenerator instance rooted until the background job finishes.
static () => ...) to guarantee at compile-time that no outer instance state is accidentally captured.*
dotnet-gcdump: Captures a lightweight, portable snapshot of the managed heap memory graph:
- Pauses the process only momentarily (sub-second).
- Produces small files (typically 10–50 MB) containing only object types and graph relationships without storing raw memory values or PII.
- Can be analyzed directly in Visual Studio or PerfView.
dotnet-dump: Captures a full OS core dump (several gigabytes):
- Includes thread stacks, CPU registers, native memory, and actual in-memory data values.
- Used for deep SOS diagnostic debugging (e.g., deadlock analysis, native interop crashes, inspecting string contents).
1. Capture the process dump:
dotnet-dump collect -p <PID> --type Full
2. Start the interactive analysis shell:
dotnet-dump analyze <dump_file>
3. Identify the types occupying the most memory on the heap:
> dumpheap -stat
4. List all instances of the leaking type:
> dumpheap -type <FullTypeName>
5. Trace the reference root chain holding a specific object instance alive:
> gcroot <ObjectAddress>
The gcroot output prints the complete chain of references from a static variable, active thread stack, or handler holding the instance alive.
1. Capture Baseline Snapshot 1 during normal load:
dotnet-gcdump collect -p <PID> -o baseline.gcdump
2. Execute repeated user operations or load tests.
3. Capture Snapshot 2 after completing the workload and running garbage collection:
dotnet-gcdump collect -p <PID> -o postload.gcdump
4. Open both files in Visual Studio Diagnostic Tools and select "Compare Dumps" (Diff).
5. Sort by Count Diff or Size Diff (Bytes). Any object count that continues to grow linearly with each cycle without dropping back down indicates the leaking type.
* Managed Leak: The objects reside on the CLR managed heap (Gen 0/1/2/LOH/POH) and are visible to
dumpheap -stat.* Native Memory Leak: Memory is allocated outside the CLR managed heap (via C/C++ native libraries, P/Invoke
Marshal.AllocHGlobal, GDI handles, or native database drivers):
- The CLR GC has no visibility into native allocations and cannot track or reclaim this memory.
- Managed heap metrics in
dotnet-counterswill look low and healthy (e.g., 200 MB), while OS task managers or container cgroups show process memory exceeding limits (e.g., 2 GB).
In Linux container environments, standard glibc memory allocators can cause high fragmentation or hide native allocations.
Diagnostics with jemalloc / MALLOC_CONF:
- Configure the container with
libjemallocprofiling enabled:ENV LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libjemalloc.so.2" ENV MALLOC_CONF="prof:true,prof_prefix:jeprof.out,lg_prof_interval:30" jemallocautomatically dumps native allocation profiles periodically.- Analyze the resulting native call trees using
jeprofto pinpoint the exact C/native library allocating unmanaged buffers.
IMemoryCache stores cached entries in memory. It causes leaks under two common configurations:
- Missing Expiration Policies: Caching dynamic keys (e.g., user IDs or search terms) without setting
AbsoluteExpirationRelativeToNoworSlidingExpiration. - Missing Size Limits (
SizeLimit): If a cache has no size cap, memory will grow unbounded under high traffic.
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 10240; // Max units
options.CompactionPercentage = 0.20; // Evicts 20% on limit breach
});
// Setting explicit entry size:
_cache.Set("key", payload, new MemoryCacheEntryOptions {
Size = 1,
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
When code dynamically generates assemblies (via
Reflection.Emit, un-cached dynamic serializers like custom XmlSerializer(typeof(T)) constructors, or uncompiled dynamic expressions):
- The CLR loads each generated assembly into uncollectible assembly load contexts by default.
- Assemblies cannot be garbage-collected individually unless they are created inside an explicit Collectible AssemblyLoadContext (
AssemblyLoadContext(isCollectible: true)). - Repeatedly generating assemblies causes native metadata heaps to grow indefinitely.
IHttpContextAccessor uses AsyncLocal<HttpContext> to track the current HTTP request context.
If an un-awaited background task (e.g.,
Task.Run) or long-running worker captures IHttpContextAccessor.HttpContext or inherits its ambient ExecutionContext:
- The entire
HttpContext(including request bodies, headers, user claims, and all scoped services resolved during the request) is kept alive in memory for the duration of the background job. - Fix: Extract primitive values (e.g.,
string userId = context.User.GetId()) and pass them explicitly into background methods instead of passingHttpContext.
If a class defines a Finalizer (
~ClassName()) and users forget to call Dispose():
- The object cannot be freed during Gen 0 sweeps; it is moved to the F-Reachable queue and promoted to Generation 1 or Generation 2.
- If the single CLR Finalizer thread is blocked or running slowly (e.g., executing slow synchronous I/O inside another class's finalizer), the F-Reachable queue grows massive, causing severe memory accumulation across all finalizable objects.
Inside
dotnet-dump analyze, run:
> dumpheap -min 85000
To check the amount of dead, unusable free space fragmenting the LOH:
> dumpheap -type Free -min 85000
If Free objects represent a large percentage of LOH segments, the heap is heavily fragmented from allocating temporary large byte arrays or string concatenations.
* Prometheus + OpenTelemetry Metrics: Scrape
process_working_set_bytes and dotnet_gc_heap_size_bytes. Alert when working set grows linearly over 24 hours without stabilizing.* Automated Dump on OOM: Configure the .NET runtime to automatically capture a mini/full dump before being killed by setting environment variables in the container:
env:
- name: DOTNET_DbgEnableMiniDump
value: "1"
- name: DOTNET_DbgMiniDumpType
value: "2" # MiniDumpWithFullMemory
- name: DOTNET_DbgMiniDumpName
value: "/dumps/crashdump.dmp"
Standard timers like
System.Threading.Timer or System.Timers.Timer are rooted by internal scheduler queues.
If a transient or scoped class creates a timer that references instance methods as callbacks, the timer keeps the instance alive permanently unless explicitly disposed:
// Memory Leak:
_timer = new Timer(OnTick, null, 1000, 1000); // Keeps 'this' rooted forever
// The Modern .NET Fix:
// Use PeriodicTimer inside a BackgroundService instead of System.Threading.Timer
using var periodicTimer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await periodicTimer.WaitForNextTickAsync(stoppingToken))
{
DoWork();
}