Introduction: Why Performance Is a Cloud-Native Imperative
Every millisecond matters. In a cloud-based world where you pay per compute-second, where users abandon pages after 3 seconds of loading, and where a single slow endpoint can cascade into a system-wide bottleneck, performance optimization isn't a luxury — it's a business requirement.
ASP.NET Core is already one of the fastest mainstream web frameworks available. But "fast by default" doesn't mean "fast in practice." Poorly written LINQ queries, careless allocations, misconfigured middleware, and naive caching strategies can erode that advantage quickly — and in Azure, those inefficiencies translate directly into higher bills and lower reliability.
This post is a practitioner's guide. We won't rehash what async/await means or how dependency injection works. Instead, we'll walk through concrete, battle-tested techniques to squeeze meaningful performance out of your ASP.NET Core applications — with code, tooling, and the reasoning behind each decision.
Let's get into it.
1. Middleware Optimization and Request Pipeline Tuning
The ASP.NET Core middleware pipeline processes every single HTTP request. The order, quantity, and weight of your middleware components have a direct impact on latency.
Order Matters — A Lot
Middleware executes in registration order. Place short-circuiting middleware (like health checks, static files, and CORS) as early as possible to avoid unnecessary work downstream.
var app = builder.Build();
// ✅ Short-circuit early — these requests don't need auth, MVC, etc.
app.UseHealthChecks("/health");
app.UseStaticFiles();
// CORS and auth come next
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
// MVC/endpoint routing last — heaviest processing
app.MapControllers();
Strip Out What You Don't Need
Every middleware you register adds overhead. Audit your pipeline ruthlessly. If you're building an API that never serves static files, remove UseStaticFiles(). If you don't use sessions, don't register session middleware.
Write Lean Custom Middleware
When writing custom middleware, avoid blocking calls and heavy allocations in the hot path.
// ❌ Anti-pattern: Allocating and doing I/O on every request
public class HeavyMiddleware
{
private readonly RequestDelegate _next;
public HeavyMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
// Allocates a new list and hits the database on EVERY request
var bannedIps = await LoadBannedIpsFromDatabase();
if (bannedIps.Contains(context.Connection.RemoteIpAddress?.ToString()))
{
context.Response.StatusCode = 403;
return;
}
await _next(context);
}
}
// ✅ Better: Cache the result, minimize per-request work
public class LeanMiddleware
{
private readonly RequestDelegate _next;
private readonly IMemoryCache _cache;
public LeanMiddleware(RequestDelegate next, IMemoryCache cache)
{
_next = next;
_cache = cache;
}
public async Task InvokeAsync(HttpContext context)
{
var bannedIps = await _cache.GetOrCreateAsync("banned-ips", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return await LoadBannedIpsFromDatabase();
});
if (bannedIps!.Contains(context.Connection.RemoteIpAddress?.ToString()))
{
context.Response.StatusCode = 403;
return;
}
await _next(context);
}
}
2. Efficient Use of Async/Await
You already know async/await. The question is whether you're using it in ways that actually help — or in ways that add overhead for no benefit.
Avoid Async for Trivial Operations
If a method is purely CPU-bound and completes in microseconds, making it async adds state machine overhead with no benefit.
// ❌ Unnecessary async — no I/O, no real awaiting
public async Task<string> GetGreetingAsync(string name)
{
return await Task.FromResult($"Hello, {name}");
}
// ✅ Just return synchronously
public string GetGreeting(string name)
{
return $"Hello, {name}";
}
Use ConfigureAwait(false) in Library Code
In library or service-layer code that doesn't need the synchronization context, use ConfigureAwait(false) to avoid unnecessary context captures.
public async Task<Product?> GetProductAsync(int id)
{
return await _dbContext.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == id)
.ConfigureAwait(false);
}
Parallelize Independent I/O Calls
When you have multiple independent async operations, use Task.WhenAll instead of awaiting them sequentially.
// ❌ Sequential — total time = sum of all calls
var user = await _userService.GetUserAsync(userId);
var orders = await _orderService.GetOrdersAsync(userId);
var recommendations = await _recommendationService.GetAsync(userId);
// ✅ Parallel — total time = longest call
var userTask = _userService.GetUserAsync(userId);
var ordersTask = _orderService.GetOrdersAsync(userId);
var recommendationsTask = _recommendationService.GetAsync(userId);
await Task.WhenAll(userTask, ordersTask, recommendationsTask);
var user = userTask.Result;
var orders = ordersTask.Result;
var recommendations = recommendationsTask.Result;
Never Block on Async Code
This is well-known, but still common in practice. Never call .Result or .Wait() on a task in synchronous code — it risks deadlocks and thread pool starvation.
// ❌ Thread pool starvation risk
public IActionResult GetData()
{
var data = _service.GetDataAsync().Result; // Blocks a thread
return Ok(data);
}
// ✅ Stay async all the way
public async Task<IActionResult> GetData()
{
var data = await _service.GetDataAsync();
return Ok(data);
}
3. Caching Strategies
Caching is one of the highest-leverage optimizations available. But choosing the right layer and invalidation strategy makes the difference between a fast app and a stale one.
In-Memory Caching
Best for: single-instance deployments, reference data that changes infrequently.
builder.Services.AddMemoryCache();
// In your service:
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _db;
public ProductService(IMemoryCache cache, AppDbContext db)
{
_cache = cache;
_db = db;
}
public async Task<List<Category>> GetCategoriesAsync()
{
return (await _cache.GetOrCreateAsync("categories", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
entry.SlidingExpiration = TimeSpan.FromMinutes(10);
return await _db.Categories
.AsNoTracking()
.OrderBy(c => c.Name)
.ToListAsync();
}))!;
}
}
Distributed Caching with Redis
Essential for: multi-instance deployments, App Service scale-out, Kubernetes pods.
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration
.GetConnectionString("Redis");
options.InstanceName = "myapp:";
});
// Usage in a service
public class CatalogService
{
private readonly IDistributedCache _cache;
private readonly AppDbContext _db;
private static readonly DistributedCacheEntryOptions CacheOptions = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15),
SlidingExpiration = TimeSpan.FromMinutes(5)
};
public async Task<ProductDetail?> GetProductAsync(int id)
{
var cacheKey = $"product:{id}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached is not null)
return JsonSerializer.Deserialize<ProductDetail>(cached);
var product = await _db.Products
.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDetail
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
Category = p.Category.Name
})
.FirstOrDefaultAsync();
if (product is not null)
{
await _cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(product),
CacheOptions);
}
return product;
}
}
Response Caching
For GET endpoints returning data that doesn't change per-user, use response caching to skip controller execution entirely.
builder.Services.AddResponseCaching();
// In pipeline
app.UseResponseCaching();
// On your controller action
[HttpGet("categories")]
[ResponseCache(Duration = 300, VaryByHeader = "Accept-Encoding")]
public async Task<IActionResult> GetCategories()
{
var categories = await _categoryService.GetAllAsync();
return Ok(categories);
}
Caveat: Response caching doesn't work with authenticated endpoints or responses that include
Set-Cookie. For those scenarios, use output caching (available in .NET 7+) or application-level caching.
4. Database Performance: EF Core Optimization
The database is almost always the primary bottleneck. EF Core is powerful, but it's easy to write queries that generate terrible SQL without realizing it.
Use AsNoTracking for Read-Only Queries
Change tracking is expensive. If you're just reading data for display, turn it off.
// ❌ Tracking enabled by default — unnecessary overhead
var products = await _db.Products
.Where(p => p.IsActive)
.ToListAsync();
// ✅ No tracking — faster, less memory
var products = await _db.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync();
For read-heavy services, consider setting it globally:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
Project Only What You Need
Select only the columns you need. Returning full entity graphs when you need three fields is wasteful.
// ❌ Fetches entire entity with all columns and nav properties
var orders = await _db.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ToListAsync();
// ✅ Project to a DTO — smaller query, less data over the wire
var orders = await _db.Orders
.AsNoTracking()
.Select(o => new OrderSummaryDto
{
OrderId = o.Id,
CustomerName = o.Customer.Name,
TotalAmount = o.Items.Sum(i => i.Price * i.Quantity),
ItemCount = o.Items.Count,
OrderDate = o.OrderDate
})
.ToListAsync();
Avoid the N+1 Query Problem
This is the single most common EF Core performance issue in production codebases.
// ❌ N+1: one query for orders, then one query PER order for items
var orders = await _db.Orders.ToListAsync();
foreach (var order in orders)
{
// This triggers a lazy-load query for EACH order
var total = order.Items.Sum(i => i.Price);
}
// ✅ Eager load with Include, or better yet, project in a single query
var orderTotals = await _db.Orders
.AsNoTracking()
.Select(o => new
{
o.Id,
Total = o.Items.Sum(i => i.Price * i.Quantity)
})
.ToListAsync();
Compiled Queries for Hot Paths
If a query runs thousands of times per minute, compile it to eliminate repeated expression tree translation.
public class ProductRepository
{
// Compiled once, reused every call
private static readonly Func<AppDbContext, int, Task<Product?>> GetByIdQuery =
EF.CompileAsyncQuery((AppDbContext db, int id) =>
db.Products
.AsNoTracking()
.FirstOrDefault(p => p.Id == id));
private readonly AppDbContext _db;
public ProductRepository(AppDbContext db) => _db = db;
public Task<Product?> GetByIdAsync(int id) => GetByIdQuery(_db, id);
}
Connection Pooling
Make sure connection pooling is configured correctly. The defaults are usually fine, but under heavy load you may need tuning.
// In your connection string:
"Server=myserver;Database=mydb;Min Pool Size=5;Max Pool Size=100;
Connection Timeout=30;Connection Lifetime=300;"
Tip: Monitor
active-db-connectionswithdotnet-countersto detect connection leaks or pool exhaustion. If you see the count climbing without dropping, you likely have aDbContextorSqlConnectionthat isn't being disposed properly.
5. Minimizing Allocations and Reducing GC Pressure
The garbage collector in .NET is excellent, but it's not free. Frequent Gen 0/1/2 collections cause latency spikes. The goal is to allocate less, not to outsmart the GC.
Use Span, ReadOnlySpan, and stackalloc
For parsing and transformation operations on hot paths, Span<T> avoids heap allocations entirely.
// ❌ String allocations on every call
public string ExtractDomain(string email)
{
var parts = email.Split('@'); // Allocates a string[]
return parts[1]; // Allocates a new string
}
// ✅ Zero-allocation with Span
public ReadOnlySpan<char> ExtractDomain(ReadOnlySpan<char> email)
{
int atIndex = email.IndexOf('@');
return email[(atIndex + 1)..]; // Slice — no allocation
}
Use ArrayPool for Temporary Buffers
When you need temporary arrays, rent them instead of allocating.
// ❌ Allocates a new byte[] on every call
public byte[] ProcessData(Stream input)
{
var buffer = new byte[8192];
int bytesRead = input.Read(buffer, 0, buffer.Length);
// ... process
return buffer;
}
// ✅ Rent from the pool — returned for reuse
public void ProcessData(Stream input)
{
var buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
int bytesRead = input.Read(buffer, 0, buffer.Length);
// ... process
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
StringBuilder for String Concatenation in Loops
This is well-known but still frequently violated:
// ❌ Creates a new string object on every iteration
string result = "";
foreach (var item in items)
{
result += item.ToString() + ", ";
}
// ✅ Single allocation, much less GC pressure
var sb = new StringBuilder(items.Count * 20); // Estimate capacity
foreach (var item in items)
{
if (sb.Length > 0) sb.Append(", ");
sb.Append(item);
}
var result = sb.ToString();
Object Pooling with ObjectPool
For expensive-to-create objects used frequently, use Microsoft.Extensions.ObjectPool.
builder.Services.AddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>();
builder.Services.AddSingleton(sp =>
{
var provider = sp.GetRequiredService<ObjectPoolProvider>();
return provider.Create(new DefaultPooledObjectPolicy<StringBuilder>());
});
// In your service:
public class ReportGenerator
{
private readonly ObjectPool<StringBuilder> _sbPool;
public ReportGenerator(ObjectPool<StringBuilder> sbPool) => _sbPool = sbPool;
public string GenerateReport(IEnumerable<ReportLine> lines)
{
var sb = _sbPool.Get();
try
{
foreach (var line in lines)
{
sb.AppendLine($"{line.Label}: {line.Value}");
}
return sb.ToString();
}
finally
{
sb.Clear();
_sbPool.Return(sb);
}
}
}
6. Logging and Diagnostics Impact on Performance
Logging is essential, but aggressive logging is a silent performance killer — especially in hot paths.
Use Source Generators for High-Performance Logging
The LoggerMessage source generator avoids boxing and string interpolation overhead.
// ❌ Interpolation allocates even when log level is disabled
_logger.LogInformation($"Processing order {orderId} for customer {customerId}");
// ❌ Structured logging is better but still has boxing overhead
_logger.LogInformation("Processing order {OrderId} for customer {CustomerId}",
orderId, customerId);
// ✅ Source-generated: zero allocation when level is disabled
public static partial class LogMessages
{
[LoggerMessage(
EventId = 1001,
Level = LogLevel.Information,
Message = "Processing order {OrderId} for customer {CustomerId}")]
public static partial void ProcessingOrder(
ILogger logger, int orderId, int customerId);
}
// Usage:
LogMessages.ProcessingOrder(_logger, orderId, customerId);
Guard Expensive Log Operations
If you're logging something that requires computation, check the level first.
// ❌ Serialization happens even if Debug logging is off
_logger.LogDebug("Request payload: {Payload}",
JsonSerializer.Serialize(request));
// ✅ Only serialize when Debug is actually enabled
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Request payload: {Payload}",
JsonSerializer.Serialize(request));
}
Right-Size Your Log Levels in Production
In production, your minimum level should typically be Warning or Information — never Debug or Trace. Configure this per-category in appsettings.Production.json:
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"MyApp.Services": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
}
7. API Response Optimization
Enable Response Compression
Compress responses at the application level — especially useful when you're not behind a reverse proxy that handles compression.
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults
.MimeTypes
.Concat(["application/json", "application/xml"]);
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Optimal;
});
// In the pipeline — place BEFORE response caching
app.UseResponseCompression();
Implement Pagination Properly
Never return unbounded collections. Cursor-based pagination is more performant than offset-based for large datasets.
// Offset-based (simpler, worse for deep pages)
[HttpGet("products")]
public async Task<IActionResult> GetProducts(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 25)
{
pageSize = Math.Clamp(pageSize, 1, 100); // Enforce maximum
var query = _db.Products.AsNoTracking().Where(p => p.IsActive);
var totalCount = await query.CountAsync();
var items = await query
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync();
return Ok(new PagedResult<ProductDto>
{
Items = items,
TotalCount = totalCount,
Page = page,
PageSize = pageSize,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
});
}
// Cursor-based (better for large datasets — avoids OFFSET)
[HttpGet("products")]
public async Task<IActionResult> GetProducts(
[FromQuery] int? afterId = null,
[FromQuery] int pageSize = 25)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var query = _db.Products
.AsNoTracking()
.Where(p => p.IsActive);
if (afterId.HasValue)
query = query.Where(p => p.Id > afterId.Value);
var items = await query
.OrderBy(p => p.Id)
.Take(pageSize + 1) // Fetch one extra to check if more exist
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync();
var hasMore = items.Count > pageSize;
if (hasMore) items.RemoveAt(items.Count - 1);
return Ok(new CursorResult<ProductDto>
{
Items = items,
HasMore = hasMore,
NextCursor = items.LastOrDefault()?.Id
});
}
Use Minimal APIs for Lightweight Endpoints
For simple endpoints, Minimal APIs have less overhead than full MVC controllers.
app.MapGet("/api/health", () => Results.Ok(new { Status = "Healthy" }));
app.MapGet("/api/products/{id:int}", async (int id, AppDbContext db) =>
await db.Products.FindAsync(id) is { } product
? Results.Ok(product)
: Results.NotFound());
8. Measuring Performance: Tools and Techniques
You can't optimize what you can't measure. Here are the tools you should be using.
BenchmarkDotNet — Micro-Benchmarks
Use BenchmarkDotNet to compare implementations with statistical rigor.
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net80)]
public class SerializationBenchmarks
{
private readonly List<Product> _products = Enumerable
.Range(1, 1000)
.Select(i => new Product { Id = i, Name = $"Product {i}", Price = i * 1.5m })
.ToList();
[Benchmark(Baseline = true)]
public string NewtonsoftJson()
{
return Newtonsoft.Json.JsonConvert.SerializeObject(_products);
}
[Benchmark]
public string SystemTextJson()
{
return System.Text.Json.JsonSerializer.Serialize(_products);
}
[Benchmark]
public byte[] SystemTextJsonUtf8()
{
return System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(_products);
}
}
// Run with: dotnet run -c Release
The [MemoryDiagnoser] attribute is critical — it shows you allocations per operation, which is often more important than raw speed.
dotnet-counters — Runtime Diagnostics
Monitor your app's runtime behavior in real time:
# Install
dotnet tool install --global dotnet-counters
# Monitor your app (find PID with dotnet-counters ps)
dotnet-counters monitor --process-id <PID> \
--counters System.Runtime,Microsoft.AspNetCore.Hosting,Microsoft.EntityFrameworkCore
# Key metrics to watch:
# - gen-0-gc-count, gen-1-gc-count, gen-2-gc-count
# - threadpool-thread-count
# - threadpool-queue-length (should stay near 0)
# - active-db-connections
# - requests-per-second
# - current-requests
Application Insights — Production Monitoring
Enable dependency tracking and custom metrics to find bottlenecks in production.
builder.Services.AddApplicationInsightsTelemetry();
// Custom metrics for business-critical operations
public class OrderService
{
private readonly TelemetryClient _telemetry;
public OrderService(TelemetryClient telemetry) => _telemetry = telemetry;
public async Task<Order> PlaceOrderAsync(OrderRequest request)
{
var stopwatch = Stopwatch.StartNew();
try
{
var order = await ProcessOrderInternal(request);
_telemetry.TrackMetric("OrderProcessing.Duration",
stopwatch.Elapsed.TotalMilliseconds);
_telemetry.TrackMetric("OrderProcessing.ItemCount",
request.Items.Count);
return order;
}
catch (Exception ex)
{
_telemetry.TrackException(ex);
throw;
}
}
}
9. Azure Deployment Performance Considerations
Choose the Right App Service Tier
Not all tiers are created equal. The Basic tier shares CPU. The Standard and Premium tiers give you dedicated compute. For performance-sensitive workloads, Premium v3 (P1v3+) offers better CPU, faster storage I/O, and support for more instances.
Enable Auto-Scaling Based on the Right Metric
CPU percentage is the default trigger, but it's not always the best signal. Consider scaling on HTTP queue length or custom metrics from Application Insights.
Use Deployment Slots to Avoid Cold Starts
Deployment slots let you warm up a new version before swapping it into production. Configure warm-up in your web.config:
<system.webServer>
<applicationInitialization doAppInitAfterRestart="true">
<add initializationPage="/health" />
<add initializationPage="/api/warmup" />
</applicationInitialization>
</system.webServer>
Use Azure Redis Cache Instead of In-Memory Cache
When running multiple instances (and you should be, for reliability), in-memory cache causes inconsistencies. Use Azure Cache for Redis as your IDistributedCache implementation.
Connection Strings and Service Configuration
// Enable connection resiliency for Azure SQL
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
sqlOptions.CommandTimeout(30);
}));
Enable HTTP/2 and Keep-Alive
builder.WebHost.ConfigureKestrel(options =>
{
options.AddServerHeader = false; // Minor — removes the Server header
options.Limits.Http2.MaxStreamsPerConnection = 100;
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
});
10. Common Performance Pitfalls and Anti-Patterns
Here are the mistakes we see most often in production ASP.NET Core applications:
| Anti-Pattern | Why It Hurts | Fix |
|---|---|---|
Calling .Result or .Wait() on tasks |
Thread pool starvation, deadlocks | Use async/await throughout |
Using Include() instead of Select()
|
Over-fetches data, large memory footprint | Project to DTOs |
| No pagination on list endpoints | Unbounded queries, timeouts, OOM | Always paginate; enforce max page size |
Logging at Debug/Trace in production |
I/O overhead on every request | Set minimum level to Warning
|
Creating HttpClient with new
|
Socket exhaustion | Use IHttpClientFactory
|
| Allocating large strings in loops | GC pressure, Gen 2 collections | Use StringBuilder or Span<T>
|
Not using AsNoTracking()
|
Unnecessary change tracking overhead | Default to no-tracking for reads |
| Catching and re-throwing exceptions | Stack trace reset, perf cost of exceptions | Use throw; not throw ex;
|
| Synchronous I/O in middleware | Blocks thread pool threads | Use async overloads exclusively |
| No caching on stable data | Repeated DB/API calls for the same data | Cache with appropriate TTL |
Performance Optimization Checklist
Apply these immediately to any ASP.NET Core project:
Request Pipeline
- [ ] Middleware is ordered correctly (short-circuit first)
- [ ] Unused middleware is removed
- [ ] Response compression is enabled
Async/Await
- [ ] No
.Resultor.Wait()calls anywhere - [ ] Independent I/O operations are parallelized with
Task.WhenAll - [ ]
ConfigureAwait(false)is used in library/service code
Caching
- [ ] Frequently read, rarely changed data is cached
- [ ] Distributed cache is configured for multi-instance deployments
- [ ] Cache entries have appropriate expiration policies
Database
- [ ]
AsNoTracking()is used on all read-only queries - [ ] Queries project to DTOs instead of loading full entities
- [ ] No N+1 query patterns exist (check with EF Core logging)
- [ ] Hot-path queries are compiled with
EF.CompileAsyncQuery - [ ] Connection strings include pooling configuration
Allocations
- [ ]
StringBuilderis used instead of string concatenation in loops - [ ]
ArrayPool<T>is used for temporary buffers - [ ]
Span<T>is used for parsing operations on hot paths - [ ] Large object allocations are minimized (avoid
byte[]> 85KB)
Logging
- [ ]
LoggerMessagesource generators are used in hot paths - [ ] Production log level is
Warningor higher by default - [ ] Expensive log payloads are guarded with
IsEnabled()
API Design
- [ ] All list endpoints are paginated with an enforced maximum
- [ ]
HttpClientis created viaIHttpClientFactory - [ ] Response compression (Brotli/Gzip) is enabled
Monitoring
- [ ] Application Insights (or equivalent) is configured
- [ ] Key operations have custom performance metrics
- [ ]
dotnet-countershas been run at least once to baseline GC and thread pool behavior
Key Takeaways
Performance optimization in ASP.NET Core isn't about finding a single silver bullet — it's about systematically identifying and eliminating waste across every layer of your application.
The highest-impact areas, in order of typical ROI:
-
Database queries — this is almost always where the biggest wins are. Fix N+1 queries, project to DTOs, add
AsNoTracking(). - Caching — cache what you can at the right layer. Even a 5-minute cache on a hot endpoint can reduce database load by 99%.
- Async discipline — don't block, don't allocate unnecessarily, parallelize where possible.
- Pipeline hygiene — strip unused middleware, order correctly, compress responses.
-
Measure everything — use
dotnet-counters, BenchmarkDotNet, and Application Insights. Don't guess, profile.
The best performance work is boring. It's not about clever tricks — it's about consistent application of known best practices, combined with measurement to validate that your changes actually matter.
Start with your slowest endpoint. Profile it. Fix the biggest bottleneck. Repeat.
Happy optimizing.
Top comments (0)