Skip to content
Go back

Practical Rate Limiting in .NET 9 (Fixed & Beyond)

3 min read

Table of contents

Open Table of contents

1. Context

Imagine a public JSON endpoint receiving unpredictable client bursts. Downstream dependencies (DB, external APIs) show rising p95 latency and thread-pool starvation during spikes. We need a protective layer that is simple, observable, and composable with future policies (auth quotas, per-customer tiers).

2. Problem Statement

Without controls: bursts (e.g., 200 reqs in <5s) cause queueing, GC pressure, and elevated error rates. We need:

3. Fixed Window Baseline

Add the fixed window limiter for a quick protective envelope.

Install the package (if not already present):

dotnet add package Microsoft.AspNetCore.RateLimiting

4. Basic Configuration (Fixed Window)

Program.cs minimal example:

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

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("fixed", o =>
    {
        o.PermitLimit = 60;                // 60 requests
        o.Window = TimeSpan.FromMinutes(1); // per 1 minute window
        o.QueueLimit = 10;                  // allow short overflow buffering
        o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
    });
});

var app = builder.Build();

app.UseRateLimiter(); // Apply globally

app.MapGet("/status", () => Results.Ok(new { ok = true }))
   .RequireRateLimiting("fixed");

app.Run();

Explanation

5. Adding Per-Client (Partitioned) Limits

Often one noisy client harms others. Use a partitioned limiter keyed by API key or IP.

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("global", o =>
    {
        o.PermitLimit = 500;
        o.Window = TimeSpan.FromMinutes(1);
        o.QueueLimit = 50;
    });

    options.AddPartitionedLimiter("per-client", httpContext =>
    {
        var clientKey = httpContext.Request.Headers["X-Api-Key"].FirstOrDefault()
                        ?? httpContext.Connection.RemoteIpAddress?.ToString()
                        ?? "anonymous";

        return RateLimitPartition.GetFixedWindowLimiter(clientKey, _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 30,
            Window = TimeSpan.FromMinutes(1),
            QueueLimit = 5,
            QueueProcessingOrder = QueueProcessingOrder.OldestFirst
        });
    });

    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.OnRejected = (context, ct) =>
    {
        context.HttpContext.Response.Headers["Retry-After"] = "10"; // seconds hint
        return context.HttpContext.Response.WriteAsJsonAsync(new
        {
            error = "rate_limit_exceeded",
            limiter = context.Lease.TryGetMetadata(MetadataName.LimiterName, out var n) ? n : null,
            detail = "Request limit exceeded. Try again soon."
        }, ct);
    };
});

app.MapGet("/data", () => "some data")
   .RequireRateLimiting("global")
   .RequireRateLimiting("per-client");

6. Strategy Comparison (When to Switch)

StrategyGood ForLimitation
Fixed WindowSimplicity, coarse fairnessEdge-of-window bursts
Sliding WindowSmoother enforcementMore bookkeeping
Token BucketBursty traffic with refillHarder to reason about steady rate
ConcurrencyLimiting simultaneous workDoesn’t cap total request count

7. Failure Modes & Trade-offs

8. Observability & Metrics

Capture:

9. Hardening Extensions

10. Key Takeaways

11. Next Steps

Explore sliding window or token bucket for smoother distribution, and integrate client-specific budgets tied to billing or plan level.


Liked it? Share this post on:
Author: Simon Berube

Written by Simon Berube

Software engineer with 15+ years designing Azure/.NET distributed systems: event-driven messaging, actor models, distributed architecture, throughput engineering and reliability design.

More about me


Next Post
Here’s some insight for our friend Jeff Goldblum