Caching & Performance
ASP.NET Core is already one of the fastest web frameworks, so most slow apps are slow because of what they do: an unnecessary database query on every page, a blocking call, a huge JSON response. A clinic's doctor list changes once a week but may be loaded thousands of times a day; computing it once and reusing it is the cheapest speed-up there is.
In this lesson you will learn to measure before optimising, avoid the common performance traps, cache data with IMemoryCache and HybridCache, cache whole responses with output caching, compress responses, and protect the app with rate limiting.
Measure first
Guessing where time goes is usually wrong. Measure:
- Log request durations (HTTP logging or the timing middleware from The Middleware Pipeline) and look at the slowest endpoints.
- Turn on EF Core command logging in Development to see how many queries a page runs and how long each takes.
- Use
dotnet-countersto watch live metrics such as requests per second and memory, anddotnet-traceto record where CPU time goes. ASP.NET Core publishes standard metrics that OpenTelemetry can send to monitoring dashboards. - Use a load-testing tool against a test environment, never production, to see how the app behaves under many users.
Optimise the endpoint that is both slow and busy, then measure again.
Common traps
// Bad: blocks a thread-pool thread while waiting for the database
var doctors = db.Doctors.ToListAsync().Result;
// Good: the thread is released while the database works
var doctors = await db.Doctors.ToListAsync();- Blocking on async code (
.Result,.Wait()) ties up threads. Under load the thread pool runs out and every request slows down. Useasyncandawaitall the way. - Creating
HttpClientwithnewfor every call can exhaust sockets. UseIHttpClientFactory(builder.Services.AddHttpClient<SmsClient>()) and inject the client. - Loading too much data. Use
Selectprojections,AsNoTracking, and paging withSkip/Take; never return a whole table. - N+1 queries. One query per item in a loop; load related data with
Includeor a projection instead. - Large responses. Send only the fields the client needs, and compress text responses.
In-memory caching with IMemoryCache
IMemoryCache stores objects in the server's memory for a time you choose:
builder.Services.AddMemoryCache();
app.MapGet("/api/doctors", async (IMemoryCache cache, ClinicDb db) =>
await cache.GetOrCreateAsync("doctors:active", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return await db.Doctors.AsNoTracking()
.Where(d => d.IsActive)
.Select(d => new DoctorDto(d.Id, d.Name, d.Speciality, d.FeeRupees))
.ToListAsync();
}));The first request runs the query and stores the list; for the next 10 minutes every request gets it from memory without touching the database. When an admin changes a doctor, remove the entry with cache.Remove("doctors:active") so the next request reloads fresh data. IMemoryCache lives inside one server process, so with several servers each has its own copy, and it is lost on restart.
HybridCache
HybridCache (package Microsoft.Extensions.Caching.Hybrid, available since .NET 9) is the newer, recommended API. It combines a fast in-memory cache with an optional distributed cache such as Redis shared by all servers, and it adds stampede protection: if 100 requests miss the cache at the same moment, only one runs the expensive query and the others wait for its result.
builder.Services.AddHybridCache();
app.MapGet("/api/doctors/{id:int}/slots", async (int id, DateOnly date, HybridCache cache, SlotService slots) =>
await cache.GetOrCreateAsync(
$"slots:{id}:{date:yyyy-MM-dd}",
async cancel => await slots.FreeSlotsAsync(id, date, cancel),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromSeconds(30) },
tags: [$"doctor:{id}"]));Tags group entries, so after a booking you can invalidate every cached slot list for that doctor with await cache.RemoveByTagAsync($"doctor:{id}"). Short expirations suit data that changes often, like free slots; build cache keys from trusted values (IDs, dates), never from raw user text.
Output caching
Output caching stores the entire HTTP response and replays it without running the endpoint at all:
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Doctors", p => p.Expire(TimeSpan.FromMinutes(5)).Tag("doctors"));
});
var app = builder.Build();
app.UseOutputCache();
app.MapGet("/api/doctors", GetDoctors).CacheOutput("Doctors");
app.MapPost("/api/admin/doctors", async (Doctor d, ClinicDb db, IOutputCacheStore store, CancellationToken ct) =>
{
db.Doctors.Add(d);
await db.SaveChangesAsync(ct);
await store.EvictByTagAsync("doctors", ct); // the next GET rebuilds the cached response
return Results.Created($"/api/doctors/{d.Id}", d);
});By default output caching only stores 200 responses to GET or HEAD requests, skips responses that set cookies, and skips authenticated requests, so one user never receives another user's private page. The default lifetime is 60 seconds. In apps with authentication, CORS or controllers, call UseOutputCache after UseCors, UseRouting, UseAuthentication and UseAuthorization. The older response caching middleware only follows HTTP cache headers and is rarely the right choice for new apps.
Response compression
Kestrel can compress responses with Brotli or gzip. When a reverse proxy such as Nginx or IIS already compresses, let it do the work. Otherwise:
builder.Services.AddResponseCompression(o => o.EnableForHttps = true);
var app = builder.Build();
app.UseResponseCompression();JSON and HTML often shrink to a fraction of their size. Static files served by MapStaticAssets are already compressed at build time.
Rate limiting
Performance also means staying up when someone floods you. The built-in rate limiter caps how often clients may call an endpoint:
using Microsoft.AspNetCore.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("booking", limiter =>
{
limiter.PermitLimit = 5;
limiter.Window = TimeSpan.FromMinutes(1);
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapPost("/api/appointments", CreateAppointment).RequireRateLimiting("booking");Here all clients together can make 5 booking requests per minute; real apps usually partition limits per user or per IP address with AddPolicy and RateLimitPartition. Note the first setting: by default rejected requests get 503 Service Unavailable, which wrongly suggests the server is broken; 429 Too Many Requests tells clients to slow down.
Common mistakes
- Optimising without measuring. Fix the slow, busy endpoints that measurements point to.
.Resultand.Wait()on tasks. Useawait.- Caching per-user data under a shared key. Include the user ID in the key, or do not cache it.
- Forgetting invalidation. Remove or evict cache entries when the data changes, or keep expirations short.
- Leaving the rate limiter's 503 default. Set
RejectionStatusCodeto 429.
Key points
- Measure with logs, EF Core command logging,
dotnet-countersanddotnet-tracebefore optimising. - Use async I/O end to end,
IHttpClientFactory, projections,AsNoTrackingand paging. IMemoryCachecaches in one process;HybridCacheadds distributed caching, tags and stampede protection.- Output caching replays whole responses; it skips authenticated requests by default and supports tag eviction.
- Response compression shrinks responses, and the rate limiter protects endpoints (set 429 as the rejection code).
Exercise
A railway enquiry API in Jaipur has GET /api/trains/{number}/schedule, whose data changes only when the timetable is revised, and GET /api/trains/{number}/live, which changes every minute. Choose a caching approach for each, write the code, and add a rate limit of 30 requests per minute to the live endpoint with a correct rejection status code.
Show solution
builder.Services.AddOutputCache(o =>
o.AddPolicy("Schedule", p => p.Expire(TimeSpan.FromHours(6)).Tag("schedule")));
builder.Services.AddHybridCache();
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.AddFixedWindowLimiter("live", l => { l.PermitLimit = 30; l.Window = TimeSpan.FromMinutes(1); });
});
var app = builder.Build();
app.UseRateLimiter();
app.UseOutputCache();
app.MapGet("/api/trains/{number:int}/schedule", (int number, TimetableService t) => t.Schedule(number))
.CacheOutput("Schedule");
app.MapGet("/api/trains/{number:int}/live", async (int number, HybridCache cache, LiveStatusService live) =>
await cache.GetOrCreateAsync($"live:{number}",
async ct => await live.GetAsync(number, ct),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromSeconds(30) }))
.RequireRateLimiting("live");The schedule is the same for everyone and rarely changes, so a long output-cache lifetime fits, with EvictByTagAsync("schedule") when the timetable is revised. Live status changes every minute, so a 30-second HybridCache entry absorbs bursts of identical requests, while stampede protection ensures only one call to the live data source per train at a time.
Quick Quiz
1. Why is db.Doctors.ToListAsync().Result harmful in a web app?
Blocking on async code wastes threads; use await instead.
2. What does HybridCache add over IMemoryCache?
HybridCache combines local and distributed caching and lets only one caller rebuild a missing entry.
3. Which responses does output caching store by default?
The defaults avoid caching private or state-changing responses.
4. What status code does the rate limiter return by default when it rejects a request?
The default is 503; set RejectionStatusCode to 429 Too Many Requests.
Learn ASP.NET Core with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
