ASP.NET Core Web API · Quality & Delivery

Performance

The ShopSmart API serves the same catalogue to thousands of shoppers per minute. Two cheap wins usually matter most: do not compute the same response twice (caching), and do not send more bytes than needed (compression). We measure both.

Advanced 7 min Lesson 32 of 37Examples target .NET 9.
0/37 in ASP.NET Core Web API
On this page · 7 min · Advanced

In simple words#

A fast API is a fast takeaway: prepared items ready (caching), small boxes (only needed fields), and staff never standing idle (async).

Caching
Output cache or Redis.
Projection
Select only needed columns.
Compression
Smaller responses.

Learning objectives#

  • Cache GET responses with output caching and evict with tags
  • Compress JSON responses with Brotli/Gzip
  • Keep database access efficient (projection, AsNoTracking, paging)
  • Measure performance before and after changes

Explanation#

Output caching (AddOutputCache, UseOutputCache, [OutputCache(PolicyName = …)]) stores whole responses. Vary by route or query values, set expirations, tag entries, and evict tags when data changes. Use Redis-backed output caching for several instances.

Response compression (AddResponseCompression with Brotli and Gzip, EnableForHttps) shrinks JSON dramatically — often 80–90% — at a small CPU cost. Many apps let the reverse proxy or CDN do it instead.

Database work dominates most APIs: project to DTOs, AsNoTracking, always paginate, avoid N+1 queries, add indexes for filters and sorts, and use ExecuteUpdate for bulk changes.

Async I/O with CancellationToken everywhere keeps threads free; never block with .Result. Reuse HttpClient via IHttpClientFactory.

Measure with load tools (k6, bombardier, NBomber), dotnet-counters and Application Insights. Optimise the slowest endpoints first, and keep before/after numbers.

Syntax#

C# Illustrative
builder.Services.AddOutputCache(o => o.AddPolicy("Catalog", p => p.Expire(TimeSpan.FromMinutes(5)).Tag("catalog")));
builder.Services.AddResponseCompression(o => { o.EnableForHttps = true; o.Providers.Add<BrotliCompressionProvider>(); });
app.UseResponseCompression();
app.UseOutputCache();

Code example: Measuring caching and compression#

C#Program.cs Illustrative
// Program.cs
using System.Net;
using Microsoft.AspNetCore.ResponseCompression;


var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                    // keep the demo output clean
builder.Services.AddControllers();
builder.Services.AddSingleton<CatalogQueries>();
builder.Services.AddOutputCache(options =>
    options.AddPolicy("Catalog", policy => policy.Expire(TimeSpan.FromMinutes(5)).SetVaryByQuery("category").Tag("catalog")));
builder.Services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
    options.Providers.Add<BrotliCompressionProvider>();
    options.Providers.Add<GzipCompressionProvider>();
});


var app = builder.Build();
app.UseResponseCompression();
app.UseOutputCache();
app.MapControllers();
app.Urls.Add("http://127.0.0.1:5230");
await app.StartAsync();


var queries = app.Services.GetRequiredService<CatalogQueries>();
using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5230") };
for (var i = 0; i < 50; i++) await http.GetAsync("/api/catalog/uncached?category=audio");
Console.WriteLine(
quot;50 requests without caching -> database queries:
{queries.Count}");
queries.Count = 0; for (var i = 0; i < 50; i++) await http.GetAsync("/api/catalog?category=audio"); Console.WriteLine(
quot;50 requests with output caching -> database queries:
{queries.Count}");
await http.PostAsync("/api/catalog/refresh", null); await http.GetAsync("/api/catalog?category=audio"); Console.WriteLine(
quot;after evicting the 'catalog' tag -> database queries:
{queries.Count}");
using var plain = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.None }) { BaseAddress = http.BaseAddress }; var raw = await plain.GetAsync("/api/catalog/uncached?category=audio"); var gzipRequest = new HttpRequestMessage(HttpMethod.Get, "/api/catalog/uncached?category=audio"); gzipRequest.Headers.AcceptEncoding.ParseAdd("gzip"); var gzipped = await plain.SendAsync(gzipRequest); var rawBytes = (await raw.Content.ReadAsByteArrayAsync()).Length; var gzipBytes = (await gzipped.Content.ReadAsByteArrayAsync()).Length; Console.WriteLine(
quot;JSON size:
{rawBytes} bytes plain, {gzipBytes} bytes with {string.Join(",", gzipped.Content.Headers.ContentEncoding)} ({100 - gzipBytes * 100 / rawBytes}% smaller)");
await app.StopAsync(); // Services/CatalogQueries.cs public record CatalogItemDto(int Id, string Name, string Category, decimal Price, string Description); public class CatalogQueries { public int Count; // counts "database" queries for the demo public IReadOnlyList<CatalogItemDto> ByCategory(string category) { Interlocked.Increment(ref Count); return Enumerable.Range(1, 40).Select(i => new CatalogItemDto(i,
quot;
{category} product {i}", category, 499m + i,
"Comfortable, durable and backed by a two-year warranty from ShopSmart.")).ToList(); } } // Controllers/CatalogController.cs using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; [ApiController] [Route("api/catalog")] public class CatalogController(CatalogQueries queries, IOutputCacheStore cache) : ControllerBase { [HttpGet] [OutputCache(PolicyName = "Catalog")] public IReadOnlyList<CatalogItemDto> Cached(string category) => queries.ByCategory(category); [HttpGet("uncached")] public IReadOnlyList<CatalogItemDto> Uncached(string category) => queries.ByCategory(category); [HttpPost("refresh")] public async Task<IActionResult> Refresh(CancellationToken ct) { await cache.EvictByTagAsync("catalog", ct); // call after products change return NoContent(); } }
Expected output
50 requests without caching -> database queries: 50
50 requests with output caching -> database queries: 1
after evicting the 'catalog' tag -> database queries: 2
JSON size: 6143 bytes plain, 632 bytes with gzip (90% smaller)

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. Without caching, every request runs the query: 50 requests, 50 queries.
  2. With output caching, the first request runs the query and the next 49 are served from the cache.
  3. Evicting the "catalog" tag (for example after an admin edits a product) makes the next request run the query again.
  4. Response compression sends the same JSON as gzip; repetitive JSON like this shrinks by around 90%.
  5. Both features are configured once in Program.cs; the controller only adds an attribute.

Real-world use case#

ShopSmart’s catalogue endpoints are output-cached for 5 minutes and evicted whenever stock or prices change; compression is handled by Azure Front Door. Average response time dropped from 180 ms to 9 ms during sales.

Common mistakes#

Caching per-user responses

Output caching a /api/me response would show one user’s data to everyone. Do not cache authenticated, personalised responses (the default policy skips requests with Authorization).

Caching without eviction

Long expirations without tag eviction serve stale prices. Evict when data changes.

Compressing in the app and the proxy

Double work and sometimes broken responses. Pick one place.

Best practices#

  • Output-cache public GETs with tags
  • Compression in one layer
  • Efficient queries before micro-optimisations
  • Load-test with real numbers

Remember this#

Same response twice? Cache it. Changed data? Evict the tag.

Brotli/Gzip makes JSON ~80–90% smaller.

Measure first, optimise the slowest endpoint.

Practice exercise#

Your task: Add a "Product" output-cache policy that varies by the id route value and expires after 2 minutes, and use it on GET /api/products/{id}.

Need a hint?
policy.Expire(TimeSpan.FromMinutes(2)).SetVaryByRouteValue("id").Tag("catalog").
Show solution
C# Illustrative
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOutputCache(options =>
    options.AddPolicy("Product", policy => policy.Expire(TimeSpan.FromMinutes(2)).SetVaryByRouteValue("id").Tag("catalog")));
var app = builder.Build();
app.UseOutputCache();
app.MapControllers();
app.Run();


// Controllers/ProductController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;


[ApiController]
[Route("api/products")]
public class ProductController : ControllerBase
{
    [HttpGet("{id:int}")]
    [OutputCache(PolicyName = "Product")]
    public IActionResult Get(int id) => Ok(new { id, name = 
quot;Product
{id}" });
}

Mini quiz#

Performance quizMultiple choice Question 1 of 3

What does output caching store?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.