DEV Community

Cover image for ASP.NET Core Performance Optimization Techniques
Adrián López
Adrián López

Posted on

ASP.NET Core Performance Optimization Techniques

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();
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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}";
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
        }))!;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

For read-heavy services, consider setting it globally:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;"
Enter fullscreen mode Exit fullscreen mode

Tip: Monitor active-db-connections with dotnet-counters to detect connection leaks or pool exhaustion. If you see the count climbing without dropping, you likely have a DbContext or SqlConnection that 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
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
    });
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
    }));
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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 .Result or .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

  • [ ] StringBuilder is 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

  • [ ] LoggerMessage source generators are used in hot paths
  • [ ] Production log level is Warning or higher by default
  • [ ] Expensive log payloads are guarded with IsEnabled()

API Design

  • [ ] All list endpoints are paginated with an enforced maximum
  • [ ] HttpClient is created via IHttpClientFactory
  • [ ] Response compression (Brotli/Gzip) is enabled

Monitoring

  • [ ] Application Insights (or equivalent) is configured
  • [ ] Key operations have custom performance metrics
  • [ ] dotnet-counters has 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:

  1. Database queries — this is almost always where the biggest wins are. Fix N+1 queries, project to DTOs, add AsNoTracking().
  2. Caching — cache what you can at the right layer. Even a 5-minute cache on a hot endpoint can reduce database load by 99%.
  3. Async discipline — don't block, don't allocate unnecessarily, parallelize where possible.
  4. Pipeline hygiene — strip unused middleware, order correctly, compress responses.
  5. 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)