newsletter

Rate Limiting in ASP.NET Core: All 4 Algorithms Explained With Code

10 min read

One misbehaving client is enough to take an API down.

It doesn't have to be an attacker.

A mobile app with a bug in its retry loop. A partner integration that polls your endpoint every 100 milliseconds. A script a colleague wrote to "quickly export the data".

Every one of those requests hits your database, your cache and your thread pool exactly like a legitimate one.

Rate limiting is the guardrail that prevents a single caller from consuming everything.

You probably know that ASP .NET Core has a built-in rate limiter. Do you know the four different algorithms and how each one behaves under the same traffic conditions?

After reading, you will be able to pick the right algorithm for each endpoint and wire it up in minutes.

In this post, we will explore:

  • Why Rate Limiting Matters and How It Works in ASP .NET Core
  • Fixed Window Limiter
  • Sliding Window Limiter
  • Token Bucket Limiter
  • Concurrency Limiter
  • Choosing the Right Algorithm
  • Partitioning by IP, User, and API Key
  • Handling Rejected Requests and Retry-After

Let's dive in.

Copied

Why Rate Limiting Matters and How It Works in ASP .NET Core

Rate limiting is a rule that says: "this caller may do this much work in this much time, and no more".

It does three jobs:

  • Protects from abuse: brute-force logins, scraping, and denial-of-service floods are cut off before they reach your handlers.
  • Protects from accidents: a broken retry loop is stopped before it takes the database down for everyone.
  • Enforces fair share: a free tier gets a small allowance, a paid tier a bigger one, and one noisy client cannot starve the rest.

Since .NET 7, rate limiting is built into ASP .NET Core.

The algorithms live in System.Threading.RateLimiting, the middleware in Microsoft.AspNetCore.RateLimiting. Both ship with the framework, so you don't need to install any NuGet packages.

Wiring it up takes three steps: register policies, add middleware and attach a policy to an endpoint.

csharp
using System.Threading.RateLimiting; using Microsoft.AspNetCore.RateLimiting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication(/* ... */); builder.Services.AddAuthorization(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; // Policies are registered here, one per section below }); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); app.MapPost("/api/auth/login", LoginEndpoint.Handle) .RequireRateLimiting("login"); app.Run();

AddRateLimiter registers the options object that holds every named policy.

UseRateLimiter adds the middleware that checks each request against the policy attached to its endpoint.

RequireRateLimiting("login") attaches a policy middleware by name.

Endpoints without a policy are not limited at all.

Middleware order matters. UseRateLimiter sits after UseAuthentication, so the user's claims are populated when the limiter runs, which makes per-user limits possible later.

Now let's explore the Rate Limiting algorithms.

Copied

Fixed Window Limiter

Imagine a bakery that sells 100 buns per hour.

At 9:00, the counter resets to 100. When the 100th bun is served, the door closes until 10:00, when the counter resets.

It does not matter whether the buns sold out in five minutes or across the hour. Only the clock brings buns back.

That is the fixed window limiter: a counter and a reset time.

csharp
builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddFixedWindowLimiter("login", limiter => { limiter.PermitLimit = 5; limiter.Window = TimeSpan.FromMinutes(1); limiter.QueueLimit = 0; limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; }); });
csharp
app.MapPost("/api/auth/login", async ( LoginRequest request, LoginHandler handler, CancellationToken cancellationToken) => { var result = await handler.HandleAsync(request, cancellationToken); return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized(); }) .RequireRateLimiting("login");

PermitLimit is how many requests the window allows. Window is how long until the counter resets.

QueueLimit controls what happens after the requests are gone.

With 0, the request is rejected immediately. With 10, up to ten requests wait for the next window instead.

QueueProcessingOrder determines whether the oldest or newest waiting request receives the first available permit.

For login, rejecting immediately is the right call. Queueing a brute-force attempt only keeps a connection open for the attacker.

Over time:

10:00:00 window opens, 5 requests available 10:00:01 requests 1 to 5 -> 200 OK 10:00:02 request 6 -> 429 Too Many Requests 10:00:59 request 7 -> 429 Too Many Requests 10:01:00 window resets, 5 requests available again
Loading diagram…

The simplicity has a well-known weak spot: the window boundary.

A client can send 5 requests at 10:00:59 and another 5 at 10:01:00. That is 10 requests in two seconds against a limit of "5 per minute". At the boundary, a fixed window lets through up to double the limit.

For login, this is harmless. Ten password guesses instead of five changes nothing. For a downstream that can handle exactly 100 requests per minute, 200 in two seconds is a real problem.

Note: the AddFixedWindowLimiter helper creates one counter shared by every caller of the policy. With the code above, five failed logins from anyone lock the endpoint for the whole world. In the "Partitioning" section, I will show how to give every client its own counter.

When to use the fixed window:

  • Login, password reset, email sending: cheap to count, and a small overshoot at the boundary does not matter.
  • Simple abuse caps: "no more than N attempts, then wait" is easy to explain to users and support staff.
  • Memory-sensitive scenarios: the cheapest algorithm, one counter and one timestamp per partition.

When not to use it:

  • When the boundary burst matters: a downstream with a hard cap, or a third-party API that bills per call.
  • When you want smooth traffic: a fixed window does nothing to spread requests out inside the window.
  • When the reset feels unfair: a client that spreads 5 requests evenly over the minute still waits a full minute for the 6th.

With the sliding window, we can fix the boundary problem.

Copied

Sliding Window Limiter

Same bakery, different bookkeeping.

Instead of resetting on the hour, the baker keeps a note of every sale in the last 60 minutes. A new customer is served only if fewer than 100 buns have been served in the previous 60 minutes, measured from the current time.

This is the window slide plus the clock. There is no magic moment where everything resets.

ASP .NET Core implements this with segments. The window is split into equal slices, each slice counts its own requests, and when the oldest slice falls out of the window, its requests come back.

csharp
options.AddSlidingWindowLimiter("public-api", limiter => { limiter.PermitLimit = 100; limiter.Window = TimeSpan.FromMinutes(1); limiter.SegmentsPerWindow = 6; limiter.QueueLimit = 0; });
csharp
app.MapGet("/api/products", async ( ProductsQuery query, CancellationToken cancellationToken) => Results.Ok(await query.GetAllAsync(cancellationToken))) .RequireRateLimiting("public-api");

SegmentsPerWindow = 6 with a one-minute window gives six segments of ten seconds.

A request at 10:00:05 lands in the first segment, and its permit comes back at 10:01:00 when that segment slides out. A request at 10:00:55 lands in the last segment, and its permit comes back at 10:01:50.

Here is an example at scale:

10:00:55 100 requests -> all 200 OK, last segment is full 10:01:01 request 101 -> 429, those 100 still count for the last 60 seconds 10:01:30 request 102 -> 429, still inside the window 10:01:50 the segment holding the 100 requests expires, 100 requests return 10:01:51 request 103 -> 200 OK
Loading diagram…

This algorithm solves the boundary burst problem. Whenever the client sends its 100 requests, it cannot send another 100 until a full minute has passed since the first ones.

More segments make the window slide more smoothly. The cost is memory: one counter per segment per partition.

6 to 10 segments is a sensible range.

When to use the sliding window:

  • Public read APIs: product catalogs, search results pages, anything where "100 requests per minute" should mean exactly that.
  • Fairness across the minute: a client cannot game the reset moment.
  • A drop-in upgrade from a fixed window: same options, plus SegmentsPerWindow, and the boundary problem disappears.

When not to use it:

  • When you want bursts on purpose: a dashboard that fires 30 requests when it opens will be blocked just as hard as by a fixed window.
  • Very large partition counts on tight memory: millions of distinct IPs each holding a segment array adds up.
  • Very short windows with many segments: the internal timer fires once per segment, and a 1-second window with 10 segments keeps it busy for nothing.

Both windows count requests over time.

The next "token bucket" algorithm counts something else: how much a client has saved up.

Copied

Token Bucket Limiter

Think of an arcade game.

The machine hands out 5 tokens every 10 seconds. Your bucket holds at most 20. Extra tokens disappear.

After a break, your bucket is full and you can play 20 games in a row. After that, your bucket refills: one game every two seconds on average.

That is the token bucket: how large a burst is allowed and how quickly the allowance refills.

csharp
options.AddTokenBucketLimiter("search", limiter => { limiter.TokenLimit = 20; limiter.TokensPerPeriod = 5; limiter.ReplenishmentPeriod = TimeSpan.FromSeconds(10); limiter.AutoReplenishment = true; limiter.QueueLimit = 0; });
csharp
app.MapPost("/api/products/search", async ( SearchRequest request, ProductSearch search, CancellationToken cancellationToken) => Results.Ok(await search.SearchAsync(request.Query, cancellationToken))) .RequireRateLimiting("search");

Every request takes one token. Every ReplenishmentPeriod, the limiter adds TokensPerPeriod tokens, but never above TokenLimit.

AutoReplenishment = true means a background timer does the refilling. If you set it to false, you need to call TryReplenish() yourself, which only makes sense when you hold the limiter object, for example in a background job throttling an external API. For web endpoints, leave it on.

Example of a user who was idle and then sent requests quickly:

10:00:00 user idle for a minute, bucket is full: 20 tokens 10:00:01 20 search requests -> all 200 OK, bucket is empty 10:00:02 request 21 -> 429 Too Many Requests 10:00:10 refill: +5 tokens -> 5 more requests succeed 10:00:20 refill: +5 tokens -> 5 more requests succeed
Loading diagram…

The sustained rate is 30 requests per minute, but the 20-request burst went through without complaint.

This is the shape of most human traffic: nothing for a while, then a flurry, then nothing again. Windows punish the flurry. The token bucket expects it.

Note: the leaky bucket, another classic algorithm, is not built into ASP .NET Core. A leaky bucket lets requests out at a fixed drip rate, and smooths bursts instead of allowing them. The closest built-in equivalent is a token bucket with QueueLimit greater than zero: extra requests wait in the queue and are released as tokens arrive.

When to use the token bucket:

  • Search, autocomplete, type-ahead: short bursts are normal user behavior, not abuse.
  • Per-plan quotas: free tier gets a small bucket with a slow refill, paid tier gets a big bucket with a fast refill.
  • "On average X per minute, but bursts up to Y are fine": this is the only built-in algorithm that models both numbers separately.

When not to use it:

  • Strict contractual limits: a full bucket plus the refill exceeds "never more than N per minute" for a short time.
  • A resource that cannot absorb a burst at all: the burst is the whole point. A fragile legacy service does not care that the average is fine.
  • Brute-force protection: an attacker who waits gets a full bucket and a free burst. A fixed window is stricter.

All three algorithms so far count requests against time. The last one ignores time completely.

Copied

Concurrency Limiter

Picture a car park with 10 spaces.

The entrance barrier does not care how many cars arrived today. It asks one question: how many are inside right now? When one leaves, a space opens immediately.

That is the concurrency limiter. It limits how many requests run at the same time, instead of how many arrive per minute.

csharp
options.AddConcurrencyLimiter("export", limiter => { limiter.PermitLimit = 3; limiter.QueueLimit = 5; limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; });
csharp
app.MapGet("/api/reports/export", async ( ReportExporter exporter, CancellationToken cancellationToken) => { var stream = await exporter.BuildCsvAsync(cancellationToken); return Results.File(stream, "text/csv", "report.csv"); }) .RequireRateLimiting("export");

Three exports can run at once. The fourth to eighth wait in the queue. The ninth is rejected with 429.

The permit is released when the response completes.

An export that takes 30 seconds holds its permit for 30 seconds. One that takes 200 milliseconds gives its space back almost at once.

An endpoint that answers in 10 milliseconds can serve 1,000 requests per minute and never have more than one in flight, so a PermitLimit of 3 never triggers. The same limiter on a 20-second endpoint trips as soon as a fourth user clicks the button.

A small queue is friendly for heavy endpoints: a user waits a few seconds instead of seeing an error. Keep it small. Every queued request holds an open connection.

Loading diagram…

When to use the concurrency limiter:

  • CPU or memory heavy endpoints: PDF and CSV generation, image processing, large report queries, calls to an AI model.
  • A downstream with its own connection cap: if the legacy system allows 5 connections, a concurrency limit of 5 keeps you on the right side of that cap.
  • Protecting the process itself: too much parallel heavy work starves the thread pool for every other endpoint.

When not to use it:

  • Cheap, fast endpoints: the limit never triggers, so it protects nothing.
  • Abuse prevention by count: an attacker sending requests one after another won't be stopped.
  • Anything documented as "requests per minute": use window or a bucket algorithm instead.
  • When clients need a retry hint: the limiter has no idea when a slot will free up.
Copied

Choosing the Right Algorithm

Here is the whole picture in one table:

AlgorithmWhat it countsAllows burstsMemory per partitionRetry-After hintBest fit
Fixed WindowRequests since the window startedYes, up to 2x at the boundarySmallest, one counterYesLogin, password reset, simple abuse caps
Sliding WindowRequests in the last N secondsNoOne counter per segmentYesPublic read APIs, fair per-minute limits
Token BucketTokens saved upYes, up to TokenLimit by designSmall, token count and timerYesSearch, bursty user traffic, plan tiers
ConcurrencyRequests in flight right nowNot applicableSmallest, one counterNoHeavy endpoints, downstreams with a cap
Loading diagram…

A quick decision guide:

  • "N attempts, then wait": fixed window.
  • "N per minute, fairly, no cheating at the boundary": sliding window.
  • "Bursts are fine, but the average must stay under X": token bucket.
  • "Never more than N of these running at once": concurrency.

Every example so far has used one counter for all callers. That is rarely what you want in production.

Copied

Partitioning by IP, User, and API Key

A limiter with a single shared counter is a limiter on your whole API. One aggressive client uses up the requests, and everyone else gets 429 for the rest of the window.

A partition is an independent counter selected by a key.

AddPolicy takes a function that runs on every request and returns the partition for it:

csharp
var loginPermitLimit = builder.Configuration.GetValue("RateLimiting:Login:PermitLimit", 5); var loginWindowSeconds = builder.Configuration.GetValue("RateLimiting:Login:WindowSeconds", 60); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("login", httpContext => { var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return RateLimitPartition.GetFixedWindowLimiter( partitionKey: ipAddress, factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = loginPermitLimit, Window = TimeSpan.FromSeconds(loginWindowSeconds), QueueLimit = 0 }); }); });

The lambda uses the client IP as the key and returns a fixed-window partition for it. The factory runs once per new key, when the first request from that IP arrives. Later requests from the same IP reuse the same limiter.

I recommend putting the rate limits inside your configuration (appsettings.json). Limits change per environment, sometimes they need to be updated in production and integration tests can override them without touching code.

Copied

The reverse proxy gotcha

Behind a load balancer or a reverse proxy, RemoteIpAddress is the proxy's address, not the client's. Every user lands in the same partition, and your limiter is broken.

The fix is the forwarded headers middleware:

csharp
builder.Services.Configure<ForwardedHeadersOptions>(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownProxies.Add(IPAddress.Parse("10.0.0.5")); }); var app = builder.Build(); app.UseForwardedHeaders(); app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter();

Now RemoteIpAddress holds the value from the X-Forwarded-For header, and the partition key is the real client again.

Only trust that header from proxies you know. Without KnownProxies or KnownNetworks, anyone can set their own X-Forwarded-For value and select a fresh partition for each request.

One IP caveat this configuration doesn't fix: a corporate NAT or a mobile carrier can put thousands of users behind a single address. For anonymous endpoints, that is the price of the IP key.

For authenticated endpoints, we can use the user instead.

Copied

Partition by user

csharp
options.AddPolicy("search", httpContext => { var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? httpContext.User.FindFirstValue("sub"); var partitionKey = userId ?? httpContext.Connection.RemoteIpAddress?.ToString() ?? "anonymous"; return RateLimitPartition.GetTokenBucketLimiter( partitionKey: partitionKey, factory: _ => new TokenBucketRateLimiterOptions { TokenLimit = 20, TokensPerPeriod = 5, ReplenishmentPeriod = TimeSpan.FromSeconds(10), AutoReplenishment = true, QueueLimit = 0 }); });

The key is the user ID from the token's claims, with the IP as a fallback for anonymous callers.

This is why UseRateLimiter comes after UseAuthentication.

Copied

Partition by API key with tiers

For a public API with plans, the partition function must look up the caller, determine the tier, and return different options for each tier.

That is a lot of work to put inside the lambda.

A policy class is cleaner and supports dependency injection:

csharp
public sealed class ApiKeyRateLimiterPolicy( IOptions<RateLimitingSettings> settings, IApiClientResolver apiClients) : IRateLimiterPolicy<string> { public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected => null; public RateLimitPartition<string> GetPartition(HttpContext httpContext) { var apiKey = httpContext.Request.Headers["X-Api-Key"].ToString(); var client = apiClients.Resolve(apiKey); if (client is null) { return RateLimitPartition.GetFixedWindowLimiter("anonymous", _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1) }); } if (client.IsInternal) { return RateLimitPartition.GetNoLimiter(client.Id); } var tier = client.Tier == ClientTier.Pro ? settings.Value.Pro : settings.Value.Free; return RateLimitPartition.GetTokenBucketLimiter(client.Id, _ => new TokenBucketRateLimiterOptions { TokenLimit = tier.TokenLimit, TokensPerPeriod = tier.TokensPerPeriod, ReplenishmentPeriod = TimeSpan.FromSeconds(tier.ReplenishmentSeconds), AutoReplenishment = true, QueueLimit = 0 }); } }
csharp
public sealed class RateLimitingSettings { public TierLimits Free { get; init; } = new(); public TierLimits Pro { get; init; } = new(); } public sealed class TierLimits { public int TokenLimit { get; init; } = 10; public int TokensPerPeriod { get; init; } = 2; public int ReplenishmentSeconds { get; init; } = 10; }
json
{ "RateLimiting": { "Free": { "TokenLimit": 10, "TokensPerPeriod": 2, "ReplenishmentSeconds": 10 }, "Pro": { "TokenLimit": 100, "TokensPerPeriod": 20, "ReplenishmentSeconds": 10 } } }

Register the settings and the policy class by name:

csharp
builder.Services.Configure<RateLimitingSettings>( builder.Configuration.GetSection("RateLimiting")); builder.Services.AddRateLimiter(options => { options.AddPolicy<string, ApiKeyRateLimiterPolicy>("api-key"); }); app.MapGet("/api/products", ProductsEndpoint.Handle) .RequireRateLimiting("api-key");

Unknown keys share one small anonymous partition. Internal services get GetNoLimiter, a partition that always says yes.

Free and Pro clients get a token bucket sized by their tier, keyed by client ID.

Note: GetPartition runs on every request and it is synchronous. The client lookup must be an in-memory dictionary or a cache, never a database call.

Copied

Handling Rejected Requests and Retry-After

By default, a rejected request gets a 503 Service Unavailable.

That is the wrong signal. A client that sees a 503 assumes the server is down and may retry more aggressively, which is exactly what you were trying to prevent.

429 Too Many Requests is the correct status. It tells the client: the server is fine, you are the problem, slow down.

The OnRejected callback runs for every rejected request and shapes the response:

csharp
builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.OnRejected = async (context, cancellationToken) => { var httpContext = context.HttpContext; var response = httpContext.Response; response.StatusCode = StatusCodes.Status429TooManyRequests; TimeSpan? retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var value) ? value : null; if (retryAfter is not null) { var seconds = (int)Math.Ceiling(retryAfter.Value.TotalSeconds); response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture); } var logger = httpContext.RequestServices.GetRequiredService<ILogger<Program>>(); logger.LogWarning("Rate limit exceeded on {Path} for {IpAddress}", httpContext.Request.Path, httpContext.Connection.RemoteIpAddress); await response.WriteAsJsonAsync(new ProblemDetails { Status = StatusCodes.Status429TooManyRequests, Title = "Too many requests", Detail = retryAfter is null ? "Rate limit exceeded. Please try again later." : $"Rate limit exceeded. Retry after {Math.Ceiling(retryAfter.Value.TotalSeconds)} seconds.", Instance = httpContext.Request.Path }, cancellationToken); }; });

The lease that rejected the request carries metadata. For window and bucket limiters, it includes how long until a permit is available again.

The handler reads it, rounds up to whole seconds, and sets the standard Retry-After header. Then it writes a ProblemDetails body so the error shape matches the rest of the API.

The concurrency limiter never sets RetryAfter metadata, because it has no clock and cannot know when a running export will finish. For that limiter, the header is absent, and the body says "try again later".

Loading diagram…

A well-behaved client reads Retry-After and waits at least that long. If the header is missing, it backs off exponentially with jitter, so a thousand blocked clients do not all retry in the same second.

Note: logging every rejection helps spot abuse, but during a real flood, the logs can cost you a lot. Prefer a counter metric for volume and drop the log line on high-traffic anonymous endpoints.

Copied

Summary

Let's recap the key takeaways:

  • Fixed window is a counter that resets on a schedule. Simplest and cheapest. Perfect for login and password reset. Lets up to double the limit through at the boundary.
  • Sliding window is a counter over the last N seconds. Removes the boundary problem for a little extra memory. The best default for public read APIs.
  • Token bucket separates burst size from sustained rate. The right choice for search, autocomplete, and plan tiers. The wrong choice when a burst is exactly what you must prevent.
  • Concurrency limits how many parallel requests there can be?. Protects heavy endpoints and fragile downstreams. Does nothing against sequential abuse and cannot tell clients when to retry.
  • Partition, or the limit is global. The Add*Limiter helpers share one counter across all callers. Use AddPolicy with a key: IP for anonymous traffic, user ID for logged-in users, API key for subscriptions.
  • Return 429 with Retry-After and a ProblemDetails body. The default 503 tells clients the wrong story. Only window and bucket limiters provide the retry hint.

Important note: State lives in process memory.

Limits are per application instance and reset on restart. Fine if you run only one service instance, have sticky sessions, or use an API gateway.

If you run multiple service instances, you need a shared store like Redis.

There is no single right limiter. Match the algorithm to what the endpoint actually needs to be protected from: a count of attempts, a fair rate, a burst allowance, or the number of things running at once.

Hope you find this newsletter useful. See you next time.

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

Covers everything: C#, ASP.NET Core, EF Core, system design — answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
Join 500+ developers

Not sure where you stand? Take the free .NET interview test:

  • Find out your real level — Junior to Senior+
  • A realistic mock .NET interview — across 13 areas of C#, .NET, ASP.NET Core and System Design

No credit card required. When you finish, you get a personalized report: your level, your strongest and weakest areas, and where to focus next — the perfect way to benchmark yourself before diving into the Playbook.

Start the free test

Master .NET and Software Architecture,
so AI can't replace you

Real-world architecture judgment, production trade-offs, and .NET in depth — the things that keep you relevant in the job market.

Plus a free PDF: 200 interview questions on C#, ASP.NET Core, EF Core & System Design.

Join 28,000+ developers already reading
No spam. Unsubscribe any time.

Enjoyed this article? Share it with your network