Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 033 / 100

Built-in rate limiting: a practical guide for developers

Protect your API with zero extra packages since .NET 7.

Expona Admin6 min read

Built-in rate limiting: your first line of defense

Rate limiting is often an afterthought in development, something you bolt on when production traffic starts misbehaving. Since .NET 7, you don't need third-party libraries or custom middleware to protect your API—built-in rate limiting gives you powerful controls right out of the box. In this article, I'll walk you through the strategies, configuration patterns, and real-world scenarios that let you implement production-grade protection with minimal code.

Why rate limiting matters

Before diving into implementation, let's be clear about what we're solving. Rate limiting controls how many requests a client can make within a time window. Without it, a single misbehaving client, bot, or malicious actor can exhaust your server's resources, degrade performance for legitimate users, and spike your infrastructure costs. I've seen scenarios where a customer's integration broke silently and started retrying thousands of times per second—rate limiting would have isolated the problem in seconds instead of causing a production incident.

The good news: ASP.NET Core's built-in implementation handles the common patterns you'll actually use, returns proper HTTP status codes, and integrates seamlessly with your routing and dependency injection.

Understanding the four strategies

Fixed window rate limiting

Fixed window divides time into discrete, non-overlapping buckets. Each bucket resets at a fixed interval. You allow N requests per bucket. It's simple, but has a classic edge case: requests can cluster at window boundaries.

Real-world example: you allow 100 requests per minute. At 11:59:59, someone makes 100 requests. At 12:00:00, the window resets, and they immediately make another 100 requests. In one second, they've made 200 requests. For most APIs, this is acceptable. For sensitive endpoints (payment processing, password reset), you might need something stricter.

csharp
builder.Services.AddRateLimiter(o =>
  o.AddFixedWindowLimiter("api", l =>
  {
    l.PermitLimit = 100;
    l.Window = TimeSpan.FromMinutes(1);
    l.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
    l.QueueLimit = 0;
  }));

Setting QueueLimit = 0 means rejected requests fail immediately rather than queuing. This is usually what you want—a client gets instant feedback to back off.

Sliding window rate limiting

Sliding window tracks requests over a rolling time period. Instead of discrete buckets, the window slides forward with each request. This eliminates the boundary spike problem but costs slightly more memory.

csharp
builder.Services.AddRateLimiter(o =>
  o.AddSlidingWindowLimiter("api", l =>
  {
    l.PermitLimit = 100;
    l.Window = TimeSpan.FromMinutes(1);
    l.SegmentsPerWindow = 10;
  }));

SegmentsPerWindow controls granularity. More segments mean more accurate enforcement but higher memory use. Ten segments per minute is usually solid—each segment represents 6 seconds of rolling history.

Token bucket rate limiting

Token bucket models a bucket that fills with tokens at a fixed rate. Each request consumes tokens. When the bucket empties, requests are rejected or queued. It's flexible because you can allow bursts (a full bucket) while maintaining a sustainable long-term rate.

Practical scenario: a search API that tolerates occasional traffic spikes from legitimate clients. You might allow 10 requests per second sustained, but permit a burst of 50 to handle sudden real-world load.

csharp
builder.Services.AddRateLimiter(o =>
  o.AddTokenBucketLimiter("api", l =>
  {
    l.TokenLimit = 50;
    l.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
    l.QueueLimit = 0;
    l.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
    l.TokensPerPeriod = 10;
    l.AutoReplenishment = true;
  }));

Here, the bucket holds 50 tokens. Every second, 10 new tokens are added (up to 50). A client can make a quick burst of 5 requests (50 tokens ÷ 10 per request), then settle into a sustainable 10 per second.

Concurrency rate limiting

Concurrency limiting is different—it caps how many simultaneous requests a client can make, regardless of time. Useful for resource-intensive operations like file uploads, report generation, or database-heavy queries.

csharp
builder.Services.AddRateLimiter(o =>
  o.AddConcurrencyLimiter("uploads", l =>
  {
    l.PermitLimit = 5;
    l.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
    l.QueueLimit = 10;
  }));

This allows 5 concurrent requests, queues up to 10 more. When one finishes, the next queued request proceeds. For a file upload endpoint handling 50 concurrent users, this prevents your server from opening 50 file handles simultaneously.

Partitioning: user, IP, or API key

Rate limiting without partitioning is blunt—you limit all traffic together. Smarter: partition by identity so each user, IP address, or API key has its own limit. One misbehaving user doesn't affect others.

csharp
builder.Services.AddRateLimiter(o =>
  o.AddFixedWindowLimiter("per-user", l =>
  {
    l.PermitLimit = 100;
    l.Window = TimeSpan.FromMinutes(1);
    l.AutoReplenishment = true;
  })
  .AddPolicy("api-policy", httpContext =>
  {
    var userId = httpContext.User.FindFirst("sub")?.Value;
    if (string.IsNullOrEmpty(userId))
      userId = httpContext.Connection.RemoteIpAddress?.ToString() ?? "anonymous";
    
    return RateLimitPartition.GetFixedWindowLimiter(userId, 
      _ => new FixedWindowRateLimiterOptions
      {
        PermitLimit = 100,
        Window = TimeSpan.FromMinutes(1),
        QueueLimit = 0
      });
  }));

This policy extracts the user ID from claims, or falls back to IP address for unauthenticated requests. Each unique ID gets 100 requests per minute independently.

For API key-based services, extract the key from the header:

csharp
.AddPolicy("api-key-policy", httpContext =>
{
  var apiKey = httpContext.Request.Headers["X-API-Key"].ToString();
  return RateLimitPartition.GetFixedWindowLimiter(
    apiKey ?? "anonymous",
    _ => new FixedWindowRateLimiterOptions
    {
      PermitLimit = 1000,
      Window = TimeSpan.FromHours(1)
    });
})

HTTP 429 and Retry-After headers

When a request exceeds the limit, ASP.NET Core automatically returns HTTP 429 (Too Many Requests). It also includes a Retry-After header telling the client how long to wait before retrying. This is part of the HTTP standard and respected by well-behaved clients.

csharp
app.UseRateLimiter();

app.MapGet("/api/data", async () =>
{
  // Your handler
  return Results.Ok(new { message = "success" });
})
.RequireRateLimiting("api-policy");

When a client hits the limit, they receive:

Header Value
HTTP Status 429 Too Many Requests
Retry-After 45 (seconds until next window or token availability)

The framework calculates this automatically based on your strategy. Fixed window? It tells you seconds until the window resets. Token bucket? It estimates wait time based on refill rate. This removes guesswork from client-side retry logic.

Applying rate limiting per endpoint or route group

Single endpoint

Apply a policy to individual routes using RequireRateLimiting:

csharp
app.MapGet("/api/search", SearchHandler)
  .RequireRateLimiting("api-policy")
  .WithName("Search")
  .WithOpenApi();

Route groups

For multiple related endpoints, group them and apply a policy once:

csharp
var apiGroup = app.MapGroup("/api")
  .RequireRateLimiting("api-policy");

apiGroup.MapGet("/search", SearchHandler);
apiGroup.MapPost("/create", CreateHandler);
apiGroup.MapGet("/{id}", GetHandler);

All three endpoints now share the same rate limit policy.

Different limits for different endpoints

Some endpoints are cheap, others expensive. Set them up differently:

csharp
builder.Services.AddRateLimiter(o =>
{
  o.AddFixedWindowLimiter("standard", l =>
  {
    l.PermitLimit = 100;
    l.Window = TimeSpan.FromMinutes(1);
  });
  
  o.AddFixedWindowLimiter("strict", l =>
  {
    l.PermitLimit = 10;
    l.Window = TimeSpan.FromMinutes(1);
  });
});

// In your endpoints:
app.MapGet("/api/list", ListHandler)
  .RequireRateLimiting("standard");

app.MapPost("/api/expensive-report", ReportHandler)
  .RequireRateLimiting("strict");

Common mistakes

  • Forgetting to call app.UseRateLimiter() — policies are registered but never enforced. Add it early in your middleware pipeline, ideally before authentication and authorization.
  • Using the same limit for all clients — free tier users and premium subscribers should have different quotas. Partition by user tier or API key plan.
  • Setting QueueLimit too high — queuing requests can mask overload and delay client feedback. For most APIs, set it to 0 and let clients see 429 immediately.
  • Not testing boundary conditions — write integration tests that verify behavior at exactly the limit (99, 100, 101 requests). Off-by-one bugs here are subtle and break in production.
  • Ignoring Retry-After in documentation — clients may not know they should read this header. Document your rate limit policies clearly, including retry strategies.

Key takeaways

  • Built-in rate limiting since .NET 7 eliminates the need for external packages—use it.
  • Choose your strategy: fixed window for simplicity, sliding window to avoid boundary spikes, token bucket for bursts, concurrency for resource constraints.
  • Partition by user ID, IP, or API key so one client doesn't harm others.
  • HTTP 429 with Retry-After headers are automatic—document them for client developers.
  • Apply limits per endpoint or route group to match the cost of each operation.
  • Test edge cases and monitor real-world traffic to tune your limits—too tight hurts users, too loose defeats the purpose.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles