Rate limiting caps how many requests a partition (IP, user, API key) can make per time window; ASP.NET Core returns 429 when the limit is exceeded. In-memory limiters are per process — not shared across App Service instances.
Since .NET 7 the types live in Microsoft.AspNetCore.RateLimiting (shared framework). You using Microsoft.AspNetCore.RateLimiting; and call AddRateLimiter — you typically do not add a separate NuGet for a new ASP.NET Core web app.
Request → partition key (user / IP)
│
▼
Fixed / sliding window
│
over limit? → 429 + JSON body
New to this → stay here. Merging a PR → minimal setup. On-call / interview → per-user partition · multi-instance reality · if an interviewer asks.
ASP.NET Core rate limiting is a high-intent topic because it sits at the intersection of security, cost control, and uptime. Bots hammer login endpoints. A buggy Angular retry loop fans out hundreds of calls. One tenant floods a shared SaaS API. Without limits, you pay in CPU, SQL, and support tickets.
Since .NET 7, ASP.NET Core ships built-in rate limiting middleware. I turn it on for public and partner APIs — especially auth, search, and export endpoints that are expensive or attractive to abuse.
When to enable rate limiting
| Enable | Skip |
|---|---|
| Public login, password reset, registration | Internal tool behind VPN with a handful of users |
| Export and expensive search endpoints | Every authenticated read on the same policy as login |
| Partner APIs with API keys you can partition on | Expecting one in-memory limiter to be global across App Service instances without Redis |
Prefer per-user (or per API key) over per-IP when you can — corporate NAT puts many good users behind one address. Split policies: tight on /api/auth/login, looser on catalog reads. A search crawler should not consume the same budget as password reset.
What teams actually need from rate limiting
Production teams usually need a working answer to:
- How do I return 429 Too Many Requests?
- Fixed window vs sliding window vs token bucket?
- Per-IP vs per-user limits?
- Will this work on multiple Azure App Service instances?
Those are the questions teams ask when APIs hit production traffic — worth answering with working code, not middleware snippets alone.
What the built-in middleware does (and does not)
It limits how many requests a partition (IP, user, API key) can make in a time window. On reject, you typically return 429.
Important limitation: the default in-memory limiters are per process. Two App Service instances do not share counters. For strict global limits across a farm, you need a distributed store (Redis) or edge limiting (API Management / reverse proxy). Still, in-process limits stop a huge class of accidental and casual abuse.
Minimal setup
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("fixed", opt =>
{
opt.PermitLimit = 100;
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueLimit = 0;
});
});
var app = builder.Build();
app.UseRateLimiter();
Apply to endpoints:
app.MapGet("/api/search", SearchAsync)
.RequireRateLimiting("fixed");
Or with attributes on controllers:
[EnableRateLimiting("fixed")]
[ApiController]
public class SearchController : ControllerBase { }
Place UseRateLimiter correctly in the pipeline (after routing; for user-based partitions, after authentication so User is available).
Partition by user when you can
Per-user is “this patient gets three walk-in slots per hour.” Per-IP is “this waiting-room door.” A whole office behind one NAT looks like one patient to an IP limiter — morning rush, mass 429s for staff who share the same egress IP. Prefer authenticated identity when present:
options.AddPolicy("per-user", httpContext =>
{
var key = httpContext.User.Identity?.IsAuthenticated == true
? httpContext.User.Identity!.Name!
: httpContext.Connection.RemoteIpAddress?.ToString() ?? "anon";
return RateLimitPartition.GetFixedWindowLimiter(
key,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
});
});
For APIs with API keys, partition on the key header instead of IP.
Algorithm cheat sheet
| Algorithm | Good for | Watch-outs |
|---|---|---|
| Fixed window | Simple quotas | Burst at window edges |
| Sliding window | Smoother than fixed | Slightly more complex |
| Token bucket | Allow short bursts | Tune refill carefully |
| Concurrency | Limit parallel expensive work | Does not cap total/minute alone |
Login / token endpoints: strict fixed or sliding limits.
Large exports: concurrency limiter + low permit count.
Read-heavy search: higher limits + caching (Redis).
QueueLimit: usually keep it at zero for public APIs
QueueLimit > 0 holds excess requests until a slot opens. Under a stampede you can accumulate thousands of waiting requests, then release pain all at once. For public Angular apps I prefer fail fast with 429 and let the client back off.
What Angular should do on 429
Teach the SPA:
- Read
Retry-Afterif you send it - Back off (exponential jitter) — do not tight-loop refresh
- Show a calm message on login brute-force limits
- Deduplicate identical in-flight GETs where possible
Example interceptor sketch: on 429, delay once, retry once, then surface an error. Pair with your global exception / ProblemDetails shape so the UI stays consistent.
You can customize the rejection response:
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(new
{
title = "Too many requests",
status = 429,
detail = "Please wait and try again.",
}, token);
};
Different policies for different endpoints
Do not apply one global limit that makes browsing painful while still leaving /api/auth/login wide open.
Suggested starting point for a SaaS API:
login: 5–10 / minute / IPrefresh: modest per usersearch: higher, per userexport: very low concurrency- health checks: disabled / excluded
Use [DisableRateLimiting] on health endpoints your load balancer probes.
Multi-instance and Redis reality check
On Azure App Service with multiple instances:
- in-memory limiter ≈ per instance
- attackers can round-robin and multiply effective quota
- for login protection at the edge, also configure WAF / Front Door / APIM limits
If you build Redis-backed partitioning, treat Redis connections as carefully as any shared resource — connection pool mistakes can starve the app worse than the abuse you tried to stop.
Load test before you celebrate
Rate limiting changes failure modes. Test:
- legitimate Angular user journeys still pass
- burst traffic receives 429, not 500
- login lockout behavior matches product expectations
- admin users are not accidentally sharing the anonymous partition
Production checklist
AddRateLimiter+UseRateLimiterordered correctly- Stricter policy on auth and expensive endpoints
- Partition key = user/API key when available
QueueLimit = 0for most public APIs- Clear 429 JSON for Angular
- Exclude health probes
- Document limits for partners
- Plan edge/distributed limits if you scale out
Failure story: Angular retry storm
An admin grid had an interceptor that retried every 401 and every network error immediately, three times, with no jitter. Combined with a token refresh race, one open dashboard tab generated hundreds of API calls per minute. SQL DTU spiked; other users felt the outage. Rate limiting on the search and list endpoints returned 429 quickly, which forced the team to fix the interceptor — the real bug — while protecting the shared database.
Rate limiting is not a substitute for correct auth refresh logic (Angular JWT interceptors), but it is an excellent safety net.
Bottom line
ASP.NET Core rate limiting is one of the highest-leverage protections you can add in an afternoon — if you tune partitions and avoid treating in-memory limits as global farm guarantees. Protect login and heavy endpoints first, teach Angular to respect 429, and escalate to distributed/edge limits when horizontal scale demands it.
Related: IHttpClientFactory for outbound resilience, SemaphoreSlim outbound throttle when you are the client hitting a partner cap, JWT auth checklist for login surfaces worth protecting. Topic map: async & threading hub.
If an interviewer asks
Fixed vs sliding window; per-IP vs per-user limits; does in-memory limiting work on multiple instances.
Strong answer: Fixed window is simple but bursts at edges; sliding is smoother. Prefer authenticated user partition over corporate NAT IP. In-memory limiters are per App Service instance — use Redis or edge (APIM/WAF) for farm-wide login protection. Return 429 with JSON Angular can parse; QueueLimit = 0 for fail-fast public APIs.