Problem Context

Rate limiting is the cheapest insurance you'll ever buy. It protects against abusive clients, runaway bots, mis-configured retry loops, and your own bad deploys. .NET 7 added System.Threading.RateLimiting; .NET 8 made it production-grade middleware with partitioned limiters; .NET 9 added per-route policies and richer queue semantics. Combined with Azure API Managementor Envoy at the edge, you get layered defense: gateway-level coarse limits + app-level fine-grained policies.

The 2026 standard responses are HTTP 429 Too Many Requests (RFC 6585) with a Retry-After header, plusRateLimit draft headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset) so well-behaved clients can self-throttle.

๐Ÿค” Sound familiar?
  • One client's retry loop took down your DB last weekend
  • You hand-rolled rate limiting with a MemoryCache dictionary
  • Your "rate limiter" doesn't survive a process restart
  • Distributed deployment + per-IP limits = wildly inconsistent enforcement

Use the .NET 9 RateLimiter middleware locally + Redis for cross-instance limits + 429 with Retry-After.

Concept Explanation

Four classic algorithms (the .NET middleware ships all four):

  • Fixed window โ€” N requests per minute, reset at the top of every minute. Cheap, bursts at boundary.
  • Sliding window โ€” same N, but bucket weighted across rolling window. Smoother.
  • Token bucket โ€” N tokens, refill at R/sec. Allows bursts up to N. Most flexible.
  • Concurrency โ€” at most N in-flight at a time (not per minute). Use for expensive endpoints.

For multi-instance deployments you need a distributed counter: Redis with INCR + EXPIRE (atomic via Lua), or a managed service (Azure API Management, Cloudflare, Kong).


flowchart LR
    C["Client"] -->|GET /api/...| GW["Edge gateway<br/>(APIM / Envoy)<br/>coarse limits"]
    GW --> APP["App instance<br/>RateLimiter middleware"]
    APP -->|INCR/EXPIRE| R["Redis<br/>(distributed counters)"]
    APP -->|429 + Retry-After| C

    style APP fill:#0078D4,color:#fff,stroke:#005a9e

Implementation

Step 1: Token bucket on .NET 9

builder.Services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    o.OnRejected = async (ctx, ct) =>
    {
        if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retry))
            ctx.HttpContext.Response.Headers.RetryAfter =
                ((int)retry.TotalSeconds).ToString();
        ctx.HttpContext.Response.ContentType = "application/problem+json";
        await ctx.HttpContext.Response.WriteAsJsonAsync(new
        {
            type   = "https://errors.example.com/rate-limited",
            title  = "Too many requests",
            status = 429
        }, ct);
    };

    o.AddTokenBucketLimiter("api", opt =>
    {
        opt.TokenLimit         = 100;
        opt.TokensPerPeriod    = 100;
        opt.ReplenishmentPeriod = TimeSpan.FromMinutes(1);
        opt.QueueLimit         = 0;       // reject immediately, don't queue
        opt.AutoReplenishment  = true;
    });
});

var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/api/data", () => "ok").RequireRateLimiting("api");

Step 2: Per-user / per-API-key partitioning

o.AddPolicy("per-user", httpContext =>
{
    var key = httpContext.User.FindFirstValue("sub")
              ?? httpContext.Request.Headers["X-Api-Key"].ToString()
              ?? httpContext.Connection.RemoteIpAddress?.ToString()
              ?? "anon";

    return RateLimitPartition.GetTokenBucketLimiter(key, _ => new TokenBucketRateLimiterOptions
    {
        TokenLimit         = 60,
        TokensPerPeriod    = 60,
        ReplenishmentPeriod = TimeSpan.FromMinutes(1),
        QueueLimit         = 0
    });
});

app.MapGet("/api/profile", ...).RequireRateLimiting("per-user");

Step 3: Tiered limits (free vs paid)

o.AddPolicy("tiered", httpContext =>
{
    var tier = httpContext.User.FindFirstValue("tier") ?? "free";
    var (limit, key) = tier switch
    {
        "enterprise" => (10_000, $"e:{httpContext.User.FindFirstValue("sub")}"),
        "pro"        => (1_000,  $"p:{httpContext.User.FindFirstValue("sub")}"),
        _            => (60,     $"f:{httpContext.User.FindFirstValue("sub") ?? httpContext.Connection.RemoteIpAddress}")
    };

    return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
    {
        PermitLimit = limit,
        Window      = TimeSpan.FromMinutes(1)
    });
});

Step 4: Distributed counter with Redis (Lua atomic)

// In-memory limiters reset per instance โ€” bad for horizontal scale.
// This Lua script atomically increments + sets TTL on first hit.
const string LUA = @"
local current = redis.call('INCR', KEYS[1])
if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return current";

public async Task<bool> AllowAsync(string key, int limit, int windowSeconds)
{
    var bucket = $"rl:{key}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds() / windowSeconds}";
    var count  = (long)await db.ScriptEvaluateAsync(
        LUA, new RedisKey[] { bucket }, new RedisValue[] { windowSeconds });
    return count <= limit;
}
// Plug into a custom .NET 9 PartitionedRateLimiter or before-middleware check.

Step 5: RateLimit headers (IETF draft 09)

// On every successful request, advertise remaining quota
app.Use(async (ctx, next) =>
{
    await next();
    if (ctx.Response.StatusCode != 429)
    {
        ctx.Response.Headers["RateLimit-Limit"]     = "60";
        ctx.Response.Headers["RateLimit-Remaining"] = remaining.ToString();
        ctx.Response.Headers["RateLimit-Reset"]     = secondsUntilReset.ToString();
    }
});

// Good clients (Stripe SDK, Polly with rate-limit awareness) self-throttle.

Step 6: Layered: APIM at the edge, app for fine-grain

// Azure API Management policy (XML) โ€” coarse, cheap, before reaching the app
// <inbound>
//   <rate-limit-by-key calls="1000" renewal-period="60"
//                      counter-key="@(context.Subscription.Id)" />
//   <quota-by-key      calls="1000000" renewal-period="2592000"
//                      counter-key="@(context.Subscription.Id)" />
// </inbound>

// App-side then enforces endpoint-specific limits:
//   POST /charges  โ†’ 10/min per user (expensive)
//   GET  /catalog โ†’ 1000/min per user (cheap, cached)

Common Pitfalls

  1. In-memory only at scale. 4 instances + a 100/min limit = up to 400/min in practice. Use Redis or a managed gateway for distributed counters.
  2. No Retry-After. Returning 429 without telling the client when to retry guarantees a thundering herd at the next second boundary.
  3. Limiting by IP behind a proxy without X-Forwarded-For. You'll either limit the load balancer or everyone behind one corporate NAT. Configure ForwardedHeaders middleware first.
  4. Same limit for all endpoints. A 1ms GET /healthz and a 2-second POST /charges need very different policies. Use per-route RequireRateLimiting.
  5. Queueing instead of rejecting. A QueueLimit > 0 with a long replenish period turns rate limiting into slow rate amplification. Reject early and let the client back off.
  6. Forgetting the auth path. If /loginisn't rate-limited, credential stuffing chews through your user table. Brute-force endpoints need stricter limits than business endpoints.

Practical Takeaways

  • .NET 9 RateLimiter + token bucket + per-user partition handles 80% of cases.
  • Distributed enforcement = Redis Lua INCR/EXPIRE, or APIM / Envoy / Cloudflare at the edge.
  • Always return 429 with Retry-After + application/problem+json body.
  • Emit RateLimit-* headers โ€” well-behaved clients will self-throttle.
  • Tier limits by plan; tier limits by endpoint cost.
  • Authentication endpoints need their own (much stricter) limits.
  • Layered defense: edge gateway (coarse) + app (per-endpoint) + DB (statement timeout) is much harder to take down.