
.NET Hidden Gems: System.Threading.RateLimiting
- Michael Stonis
- Dotnet
- February 4, 2026
Table of Contents
A Library Hiding in Plain Sight
Sometimes the best tools are the ones you walk past every day without noticing. System.Threading.RateLimiting is one of those tools. It shipped as part of the ASP.NET Core rate limiting middleware, and most developers assume it only works in that context. They would be wrong.
This library is a standalone NuGet package that works on any .NET platform. Mobile apps, desktop apps, console applications, Blazor. If it runs .NET, you can use System.Threading.RateLimiting.
The Problem It Solves
If you have been writing .NET applications for any length of time, you have probably needed to control concurrent access to a resource. Maybe you have a repository method that should not run in parallel. Maybe you need to gate access to an external API. Maybe you just want to make sure only one operation happens at a time.
The traditional solutions are things like SemaphoreSlim or custom locking mechanisms. They work, but they are verbose to set up and easy to get wrong. You have to remember to release the semaphore, handle exceptions properly, and configure the initial counts correctly.
RateLimiter gives you all of that functionality with a cleaner API and some additional features that make common patterns trivial to implement.
Setting Up a Single Processing Gate
Here is my go-to helper for creating a rate limiter that allows only one operation at a time:
public static class RateLimiters
{
public static RateLimiter SingleProcessing(int queueLimit = int.MaxValue)
{
return new ConcurrencyLimiter(
new ConcurrencyLimiterOptions
{
PermitLimit = 1,
QueueLimit = queueLimit,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
});
}
}
This creates a limiter that only allows one permit at a time. Any additional requests get queued and processed in order. The QueueProcessingOrder.OldestFirst setting means requests are handled in the order they arrived, which is usually what you want.
Using It Like an Async Lock
Once you have a limiter, using it is straightforward:
using var lease = await _rateLimiter.AcquireAsync();
if (lease.IsAcquired)
{
// Your code runs here with exclusive access
}
The lease pattern means the permit is automatically released when you leave the using block. No manual cleanup required. If something throws an exception, the permit still gets released.
Why Not Just Use SemaphoreSlim
You could achieve similar results with SemaphoreSlim, and I have done exactly that many times over the years. But there are a few reasons I prefer RateLimiter for new code.
First, the queue processing order is configurable. With SemaphoreSlim, you do not get guarantees about which waiting thread gets the permit next. RateLimiter lets you specify OldestFirst or NewestFirst, which makes your code more predictable.
Second, the API is cleaner. You create a limiter with explicit options, acquire a lease, and check if it was acquired. The intent is clear from the code.
Third, you get statistics. You can query how many permits are available, how many requests are queued, and other useful metrics. Helpful for debugging and monitoring.
Service Layer Protection
One of my favorite uses is protecting service layer methods from concurrent execution:
public class DataSyncService
{
private readonly RateLimiter _syncLimiter = RateLimiters.SingleProcessing();
public async Task<bool> SyncDataAsync()
{
using var lease = await _syncLimiter.AcquireAsync();
if (!lease.IsAcquired) return false;
// Sync logic here
return true;
}
}
This guarantees that only one sync operation runs at a time. If the user taps the sync button multiple times, the requests queue up and execute in order. No race conditions. No duplicate work.
Repository Pattern Integration
For repository classes, you can use rate limiters to prevent concurrent database access:
public class CustomerRepository
{
private readonly RateLimiter _writeLimiter = RateLimiters.SingleProcessing();
public async Task SaveCustomerAsync(Customer customer)
{
using var lease = await _writeLimiter.AcquireAsync();
if (!lease.IsAcquired)
throw new InvalidOperationException("Could not acquire write lock");
// Database write logic
}
}
This ensures that write operations are serialized, which can help avoid contention issues with SQLite or other databases that do not handle concurrent writes well.
Rate Limiting External API Calls
The library shines when you need to throttle calls to an external API:
private readonly RateLimiter _apiLimiter = new TokenBucketRateLimiter(
new TokenBucketRateLimiterOptions
{
TokenLimit = 10,
TokensPerPeriod = 5,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 100,
});
This creates a token bucket that allows 10 requests initially, then replenishes 5 tokens per second. Requests beyond the limit queue up. This is exactly what you need when an API has rate limits and you want to avoid hitting them.
Fixed Window Rate Limiting
When an API specifies hard limits like “100 requests per minute” with a reset at the start of each window, FixedWindowRateLimiter is the right choice:
// Limit to 100 requests per minute (resets at the start of each minute)
private readonly RateLimiter _fixedWindowLimiter = new FixedWindowRateLimiter(
new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 50,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true,
});
public async Task<WeatherData?> GetWeatherAsync(string city)
{
using var lease = await _fixedWindowLimiter.AcquireAsync();
if (!lease.IsAcquired)
{
// Rate limit exceeded, handle gracefully
return null;
}
return await _httpClient.GetFromJsonAsync<WeatherData>($"/api/weather/{city}");
}
The fixed window approach is simple and predictable. When the window resets, you get all your permits back at once.
Sliding Window Rate Limiting
Fixed windows have one drawback: the burst problem. If you use 99 requests at the end of one window and 100 at the start of the next, you have made 199 requests in a short span. SlidingWindowRateLimiter smooths this out:
// Smoother rate limiting: 60 requests per minute, evaluated over 6 segments
private readonly RateLimiter _slidingWindowLimiter = new SlidingWindowRateLimiter(
new SlidingWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6, // 10-second segments
QueueLimit = 25,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true,
});
public async Task<SearchResult?> SearchAsync(string query)
{
using var lease = await _slidingWindowLimiter.AcquireAsync();
if (!lease.IsAcquired)
{
throw new RateLimitExceededException("Search API rate limit exceeded");
}
return await _searchClient.SearchAsync(query);
}
With 6 segments in a 1-minute window, permits are released gradually as older requests age out of the sliding window. This prevents the burst spikes you get with fixed windows.
Real World Example: GitHub API
Here is a practical example for a real API with known limits:
public class GitHubApiClient
{
// GitHub API: 5000 requests per hour
// Using sliding window to spread requests evenly
private readonly RateLimiter _rateLimiter = new SlidingWindowRateLimiter(
new SlidingWindowRateLimiterOptions
{
PermitLimit = 5000,
Window = TimeSpan.FromHours(1),
SegmentsPerWindow = 60, // 1-minute segments
QueueLimit = 100,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true,
});
public async Task<Repository?> GetRepositoryAsync(string owner, string repo)
{
using var lease = await _rateLimiter.AcquireAsync();
if (!lease.IsAcquired)
{
// Log and return cached data or null
_logger.LogWarning("GitHub rate limit reached, request queued too long");
return null;
}
return await _httpClient.GetFromJsonAsync<Repository>(
$"/repos/{owner}/{repo}");
}
}
Wrapping Up
System.Threading.RateLimiting is one of those libraries that solves problems you might not even realize you have. Once you start using it, you find opportunities everywhere. Every time you reach for a lock or a semaphore, consider whether a rate limiter would give you a cleaner solution.


