API performance optimisation addresses the full stack of concerns: network payload size (compression), protocol efficiency (HTTP/2), allocation patterns (.NET memory), and query efficiency (EF Core). The most impactful single change for most APIs is enabling response compression โ a typical JSON API response compresses 60โ80%, dramatically reducing bandwidth and improving perceived response times on mobile or slow connections. Beyond compression, async streaming and careful allocation avoidance are the next highest-leverage improvements.
Response Compression and HTTP/2
// dotnet add package Microsoft.AspNetCore.ResponseCompression
// โโ Response compression โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
builder.Services.AddResponseCompression(opts =>
{
opts.EnableForHttps = true; // enable for HTTPS (off by default โ BREACH attack)
opts.Providers.Add<BrotliCompressionProvider>(); // ~15% better than gzip
opts.Providers.Add<GzipCompressionProvider>(); // fallback
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
{
"application/json",
"application/problem+json",
});
});
builder.Services.Configure<BrotliCompressionProviderOptions>(opts =>
opts.Level = CompressionLevel.Fastest); // speed vs ratio trade-off
app.UseResponseCompression(); // before static files and controllers
// โโ Enable HTTP/2 explicitly (default in Kestrel for HTTPS) โโโโโโโโโโโโโโ
builder.WebHost.ConfigureKestrel(opts =>
{
opts.ListenAnyIP(5001, listenOpts =>
{
listenOpts.UseHttps();
listenOpts.Protocols = HttpProtocols.Http1AndHttp2;
});
});
// โโ IAsyncEnumerable streaming โ for large collections โโโโโโโโโโโโโโโโโโโโ
// Returns rows as they are read from the database โ no buffering entire list
[HttpGet("export")]
public async IAsyncEnumerable<PostSummaryDto> ExportAll(
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var post in _db.Posts
.AsNoTracking()
.Where(p => p.IsPublished)
.OrderBy(p => p.Id)
.Select(p => new PostSummaryDto { Id = p.Id, Title = p.Title, Slug = p.Slug })
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return post;
}
}
// Response: JSON array streamed as rows are read โ no full-list buffering
// A 100,000 post export uses minimal memory regardless of collection size
EnableForHttps = true is required but comes with a caveat: the BREACH attack can exploit HTTP compression over HTTPS when attacker-controlled data is mixed with secrets in the same compressed response. For public API endpoints that never mix user-controlled input with secrets in the same response body, this risk is minimal. For endpoints that do mix user input with tokens or other secrets, avoid compressing those specific responses or use CSRF tokens to mitigate BREACH.IAsyncEnumerable<T> return types for export and reporting endpoints that return large collections. ASP.NET Core streams the JSON array incrementally as rows are yielded, keeping memory usage constant regardless of result set size. Without streaming, a 100,000-row export loads all rows into memory, allocates a large list, serialises it all to JSON, then sends โ potentially gigabytes of heap allocation. With streaming, only a handful of rows are in memory at any time. The Angular client receives the same JSON array structure either way..Result, .Wait(), or .GetAwaiter().GetResult() in ASP.NET Core. These create deadlocks under load by blocking thread pool threads while waiting for async completions, starving the runtime of threads to process other requests. The exception filter, synchronous middleware, and occasionally EF Core extension methods are common places this appears accidentally. Use await everywhere, and if you are in a context where async is unavailable, restructure the code rather than blocking.Minimal API Performance Comparison
// โโ Minimal API โ higher throughput for simple endpoints โโโโโโโโโโโโโโโโโโ
// 30โ40% fewer allocations vs controller-based for simple CRUD
// Controller-based (standard approach โ full MVC pipeline)
[HttpGet("{id:int}")]
public async Task<ActionResult<PostDto>> GetById(int id, CancellationToken ct)
=> Ok(await _service.GetByIdAsync(id, ct));
// Minimal API equivalent (skips controller overhead)
app.MapGet("/api/posts/{id:int}", async (
int id,
IPostService service,
CancellationToken ct) =>
{
var post = await service.GetByIdAsync(id, ct);
return post is null ? Results.NotFound() : Results.Ok(post);
})
.RequireAuthorization()
.WithName("GetPostById")
.WithOpenApi();
// Use Minimal APIs for: health endpoints, webhooks, simple proxies
// Use Controller-based for: complex APIs with many actions, full feature set
Common Mistakes
Mistake 1 โ Not enabling response compression (large JSON payloads over mobile)
โ Wrong โ 500KB uncompressed JSON response; mobile Angular clients on slow connections wait seconds.
โ Correct โ Brotli compression typically reduces JSON to 80โ120KB โ 4โ6ร bandwidth reduction.
Mistake 2 โ Blocking async code with .Result (thread pool starvation under load)
โ Wrong โ var post = _service.GetByIdAsync(id).Result; deadlocks under concurrent requests.
โ
Correct โ always await _service.GetByIdAsync(id, ct) throughout the entire call chain.