Rate limiting caps how many requests one client can make in a window. It stops a bursty caller from starving everyone else, and it keeps a slow backend from falling over. ASP.NET Core ships four limiters in Microsoft.AspNetCore.RateLimiting, and each one below has a live demo you can change.

The four limiters

Limiter What it counts Good for
Fixed window Requests in a window that resets Simple per-user caps
Sliding window Requests in a rolling window of segments Fewer bursts at window edges
Token bucket Tokens that refill at a set rate Bursts with a steady average
Concurrency Requests running at the same time Slow, expensive endpoints

Fixed window is the simplest. Sliding window smooths the edge where one window ends and the next starts. Token bucket lets a client burst up front, then settle at the refill rate. Concurrency ignores time and caps work in flight.

Which one should I use?

You want Use
A simple per-user cap, and edge bursts are fine Fixed window
The same, but without edge bursts Sliding window
Bursts up front, then a steady average Token bucket
A cap on work in flight, ignoring time Concurrency

Start with fixed window. Move to sliding or token bucket only when the edge burst or the burst shape actually causes a problem.

Setup

Add the limiter to the service collection, then add the middleware. Attach a policy to an endpoint with RequireRateLimiting.

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.AddFixedWindowLimiter("fixed", o =>
    {
        o.PermitLimit = 4;
        o.Window = TimeSpan.FromSeconds(10);
        o.QueueLimit = 2;
    });
});

var app = builder.Build();

app.UseRateLimiter();

app.MapGet("/", () => "ok").RequireRateLimiting("fixed");

app.Run();

Requests past the limit get 429 by default. QueueLimit lets a few extra requests wait in a queue instead of failing straight away.

Fixed window

A fixed window allows PermitLimit requests inside each Window, then resets the counter when the window ends. The reset is the weakness: a client can send PermitLimit requests at the end of one window and PermitLimit more at the start of the next, for double the limit in one burst.

0 of 4 used
options.AddFixedWindowLimiter("fixed", o =>
{
    o.PermitLimit = 4;
    o.Window = TimeSpan.FromSeconds(10);
    o.QueueLimit = 2;
});

Sliding window

A sliding window splits the Window into SegmentsPerWindow segments and rolls them forward. The count is the sum of the segments, so a burst spreads out instead of hitting the edge of a fixed window. It costs more memory: one counter per segment.

0 of 6 used
options.AddSlidingWindowLimiter("sliding", o =>
{
    o.PermitLimit = 6;
    o.Window = TimeSpan.FromSeconds(10);
    o.SegmentsPerWindow = 4;
});

Token bucket

A token bucket holds up to TokenLimit tokens and refills at TokensPerPeriod every ReplenishmentPeriod. Each request takes one token. A client can burst until the bucket is empty, then it is capped at the refill rate.

10.0 of 10 tokens
options.AddTokenBucketLimiter("token", o =>
{
    o.TokenLimit = 10;
    o.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
    o.TokensPerPeriod = 2;
    o.AutoReplenishment = true;
});

Concurrency

A concurrency limiter caps how many requests run at once. It ignores how long each request takes and how many arrive in a window. Use it for endpoints that are expensive to run in parallel, like a call to an LLM or a report build.

0 of 3 running
options.AddConcurrencyLimiter("concurrency", o =>
{
    o.PermitLimit = 4;
    o.QueueLimit = 2;
});

Per-client limits

The limiters above are global. To limit per user or per IP, return a partition with a key.

options.AddPolicy("per-ip", context =>
    RateLimitPartition.GetFixedWindowLimiter(
        context.Connection.RemoteIpAddress?.ToString() ?? "anon",
        _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 20,
            Window = TimeSpan.FromMinutes(1)
        }));

Each key gets its own counter, so one client hitting the limit does not block the rest.

Rejection responses

Return something useful instead of a bare 429. The OnRejected callback sets the status, the Retry-After header, and a body.

options.OnRejected = async (context, cancellationToken) =>
{
    context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
    context.HttpContext.Response.Headers.RetryAfter = "10";
    await context.HttpContext.Response.WriteAsync(
        "Slow down. Try again later.", cancellationToken);
};