ASP.NET Core Web APIs · Configuration, Security, Data and Operations
Output Caching, Rate Limiting and Health Checks
Cache GET responses with output caching, protect endpoints with the rate limiter, and expose liveness and readiness health checks.
What you'll build
A catalogue endpoint cached with output caching (varying by query), a login endpoint limited to a few attempts per window with the rate limiter (429 with Retry-After), and health checks split into liveness and readiness, where readiness reports a failing dependency with 503.
The idea
- Output caching (
AddOutputCache+CacheOutput) stores whole responses on the server; vary by query, header or route values. Use a distributed store (Redis) when running several instances. - Rate limiting (
AddRateLimiter) offers fixed window, sliding window, token bucket and concurrency limiters, partitioned per user, IP or API key. - Health checks: liveness ("is the process alive?") shouldn't check dependencies; readiness ("can it take traffic?") checks the database and other required services.
The code
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
var database = new FakeDatabase();
var builder = WebApplication.CreateBuilder();
builder.Services.AddOutputCache();
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = 429;
o.OnRejected = (ctx, _) =>
{
ctx.HttpContext.Response.Headers.RetryAfter = "60";
return ValueTask.CompletedTask;
};
o.AddPolicy("login", ctx => RateLimitPartition.GetFixedWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 3, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});
builder.Services.AddHealthChecks()
.AddCheck("database", () => database.Up ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy("connection refused"), tags: ["ready"]);
var app = builder.Build();
app.UseRateLimiter();
app.UseOutputCache();
var renders = 0;
app.MapGet("/catalogue", (int page = 1) => new { page, render = ++renders })
.CacheOutput(p => p.Expire(TimeSpan.FromSeconds(30)).SetVaryByQuery("page"));
app.MapPost("/login", () => "checked credentials").RequireRateLimiting("login");
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
app.Urls.Add("http://127.0.0.1:0");
await app.StartAsync();
using var http = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
foreach (var path in new[] { "/catalogue?page=1", "/catalogue?page=1", "/catalogue?page=2" })
await Show(await http.GetAsync(path), $"GET {path}");
for (var i = 1; i <= 4; i++)
{
var r = await http.PostAsync("/login", null);
Console.WriteLine($"login attempt {i} -> {(int)r.StatusCode}{(r.Headers.RetryAfter is { } ra ? $" Retry-After: {ra}" : "")}");
}
await Show(await http.GetAsync("/health/ready"), "ready (db up)");
database.Up = false;
await Show(await http.GetAsync("/health/live"), "live (db down)");
await Show(await http.GetAsync("/health/ready"), "ready (db down)");
await app.StopAsync();
static async Task Show(HttpResponseMessage r, string label)
{
var body = await r.Content.ReadAsStringAsync();
Console.WriteLine($"{label} -> {(int)r.StatusCode} {body}");
}
class FakeDatabase { public bool Up { get; set; } = true; }
Output
GET /catalogue?page=1 -> 200 {"page":1,"render":1}
GET /catalogue?page=1 -> 200 {"page":1,"render":1}
GET /catalogue?page=2 -> 200 {"page":2,"render":2}
login attempt 1 -> 200
login attempt 2 -> 200
login attempt 3 -> 200
login attempt 4 -> 429 Retry-After: 60
ready (db up) -> 200 Healthy
live (db down) -> 200 Healthy
fail: Microsoft.Extensions.Diagnostics.HealthChecks.DefaultHealthCheckService[103]
Health check database with status Unhealthy completed after 0.017ms with message 'connection refused'
ready (db down) -> 503 UnhealthyRun this on your computer — see “Get Started” for setup.
Common mistakes
- Output-caching authenticated or per-user responses (the default policy skips requests with Authorization headers and cookies; don't override that carelessly).
- Rate limiting per server instance only while running many instances; the built-in limiter is in-process, so use a gateway or distributed limiter for global limits.
- Liveness checks that test the database: a database outage restarts every pod and makes things worse.
UseRateLimiter/UseOutputCacheplaced so that they don't run for the endpoints you meant to protect (with explicitUseRouting, they must come after it).
Key points
- Output caching for public GET responses, varied by the inputs that change them.
- Rate limiter policies partitioned per caller; respond 429 with Retry-After.
- Separate liveness (process) and readiness (dependencies) health checks.
Further reading: Output caching · Rate limiting middleware · Health checks
Practice questions
Why shouldn't a liveness check include the database?
Answer
Liveness failures restart the process; a database outage would restart every instance without fixing anything. Dependencies belong in readiness checks, which only stop traffic.
How do you rate limit per client instead of globally?
Answer
Use a partitioned limiter (RateLimitPartition.Get...Limiter) keyed by user ID, API key or IP address, so each caller has its own budget.
What does SetVaryByQuery do in output caching?
Answer
It stores separate cache entries per value of the listed query parameters, so page=1 and page=2 are cached independently.
Open in the app to mark lessons complete, take notes and track your progress.