DistributedRateLimiter 1.0.3
dotnet add package DistributedRateLimiter --version 1.0.3
NuGet\Install-Package DistributedRateLimiter -Version 1.0.3
<PackageReference Include="DistributedRateLimiter" Version="1.0.3" />
<PackageVersion Include="DistributedRateLimiter" Version="1.0.3" />
<PackageReference Include="DistributedRateLimiter" />
paket add DistributedRateLimiter --version 1.0.3
#r "nuget: DistributedRateLimiter, 1.0.3"
#:package DistributedRateLimiter@1.0.3
#addin nuget:?package=DistributedRateLimiter&version=1.0.3
#tool nuget:?package=DistributedRateLimiter&version=1.0.3
DistributedRateLimiter
Distributed rate limiting for .NET backed by your existing database. No Redis, no message broker, no extra infrastructure.
Works in HTTP pipelines and background services — any code that calls an external API or shared resource can enforce a rate limit that's consistent across every running instance.
How it works
Each rate limit check is handled atomically in your existing database, so all running instances share the same state automatically — no in-memory counters that diverge across pods.
Instance A Instance B
│ │
│ POST /orders │ POST /orders
│ → CheckAsync("user:42", policy) │ → CheckAsync("user:42", policy)
│ → INSERT ... ON CONFLICT │ → INSERT ... ON CONFLICT
│ DO UPDATE SET count + 1 │ DO UPDATE SET count + 1
│ ← count = 1 ✓ allowed │ ← count = 2 ✓ allowed
│ │
│ POST /orders (101st request) │
│ → CheckAsync("user:42", policy) │
│ ← count = 101 ✗ 429 │
The database row is the single source of truth. No synchronisation needed between instances.
Algorithms
| Algorithm | Best for |
|---|---|
| SlidingWindow | Smooth enforcement — spreads requests evenly across time |
| FixedWindow | Simpler and cheaper — slightly bursty at window boundaries |
| TokenBucket | Burst-tolerant — allows short spikes up to a capacity, then enforces a steady refill rate |
Projects
| Project | Target | Description |
|---|---|---|
DistributedRateLimiter.Core |
netstandard2.0 |
Interfaces, models, and service contracts — no dependencies |
DistributedRateLimiter |
net8.0;net9.0;net10.0 |
Middleware, cleanup worker, and DI registration |
DistributedRateLimiter.Providers.Postgres |
net8.0;net9.0;net10.0 |
PostgreSQL provider via Npgsql |
DistributedRateLimiter.Providers.MsSql |
net8.0;net9.0;net10.0 |
SQL Server provider via Microsoft.Data.SqlClient |
DistributedRateLimiter.Providers.MySql |
net8.0;net9.0;net10.0 |
MySQL/MariaDB provider via MySqlConnector |
Quick start
1. Install packages
dotnet add package DistributedRateLimiter
dotnet add package DistributedRateLimiter.Providers.Postgres
2. Register with the host
using DistributedRateLimiter;
using DistributedRateLimiter.Core.Model;
using DistributedRateLimiter.Providers.Postgres;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbRateLimiter(
new PostgresRateLimitStore(connectionString, tableName: "__rate_limits"),
opts => opts.AddPolicy(new RateLimitPolicy
{
Name = "api",
Algorithm = RateLimitAlgorithm.SlidingWindow,
Limit = 100,
Window = TimeSpan.FromMinutes(1)
}));
var app = builder.Build();
3. Add the middleware
// Limit by authenticated user, falling back to IP
app.UseDbRateLimiter(
keySelector: ctx => ctx.User.Identity?.Name
?? ctx.Connection.RemoteIpAddress?.ToString()
?? "anon",
policyName: "api");
app.MapControllers();
app.Run();
That's it. Every request now increments a shared counter in your database. When a key exceeds the limit the middleware returns 429 Too Many Requests with standard rate limit headers.
Response headers
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests allowed in the window |
X-RateLimit-Remaining |
Requests remaining in the current window |
Retry-After |
Seconds until the client may retry (only present on 429 responses) |
Multiple policies
Register as many policies as you need — each is identified by name:
builder.Services.AddDbRateLimiter(
new PostgresRateLimitStore(connectionString, "__rate_limits"),
opts =>
{
opts.AddPolicy(new RateLimitPolicy
{
Name = "authenticated",
Algorithm = RateLimitAlgorithm.SlidingWindow,
Limit = 1000,
Window = TimeSpan.FromMinutes(1)
});
opts.AddPolicy(new RateLimitPolicy
{
Name = "anonymous",
Algorithm = RateLimitAlgorithm.SlidingWindow,
Limit = 20,
Window = TimeSpan.FromMinutes(1)
});
opts.AddPolicy(new RateLimitPolicy
{
Name = "webhooks",
Algorithm = RateLimitAlgorithm.TokenBucket,
BucketCapacity = 500,
RefillRatePerSecond = 50
});
});
app.UseDbRateLimiter(
keySelector: ctx => ctx.User.Identity?.Name
?? ctx.Connection.RemoteIpAddress?.ToString()
?? "anon",
policyNameSelector: ctx => ctx.User.Identity?.IsAuthenticated == true
? "authenticated"
: "anonymous");
Using in background services
IDbRateLimiter lives in DistributedRateLimiter.Core and has no HTTP dependency — inject it anywhere.
Register the policy at startup alongside your other policies:
builder.Services.AddDbRateLimiter(
new PostgresRateLimitStore(connectionString, "__rate_limits"),
opts =>
{
opts.AddPolicy(new RateLimitPolicy { Name = "api", /* ... */ });
opts.AddPolicy(new RateLimitPolicy
{
Name = "payment-gateway",
Algorithm = RateLimitAlgorithm.TokenBucket,
BucketCapacity = 100,
RefillRatePerSecond = 100
});
});
Then inject IDbRateLimiter and call it by name:
using DistributedRateLimiter.Core.Interface;
public class OrderProcessingWorker : BackgroundService
{
private readonly IDbRateLimiter _limiter;
private readonly IOrderRepository _orders;
private readonly IPaymentGateway _payments;
public OrderProcessingWorker(
IDbRateLimiter limiter,
IOrderRepository orders,
IPaymentGateway payments)
{
_limiter = limiter;
_orders = orders;
_payments = payments;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var orders = await _orders.GetPendingAsync(ct);
foreach (var order in orders)
{
// Enforce the payment gateway's per-merchant API quota
// consistently across all running instances of this worker
var result = await _limiter.CheckAsync(
key: $"payment-gateway:{order.MerchantId}",
policyName: "payment-gateway",
ct);
if (!result.Allowed)
{
await Task.Delay(result.RetryAfter, ct);
continue;
}
await _payments.ChargeAsync(order, ct);
}
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
}
This is where a database-backed limiter has a meaningful advantage over in-memory alternatives — if you run 5 instances of this worker, an in-memory limiter allows 5× the intended quota. The shared database counter enforces the real limit regardless of instance count.
Cleanup
Rate limit rows accumulate over time. Register the built-in cleanup worker to purge them on a schedule:
// Default: purge rows older than 2 hours, run every 30 minutes
builder.Services.AddDbRateLimiterCleanup();
// Custom intervals
builder.Services.AddDbRateLimiterCleanup(opts =>
{
opts.MaxAge = TimeSpan.FromHours(1); // set to at least your longest window
opts.Interval = TimeSpan.FromMinutes(10);
});
RateLimitCleanupOptions lives in DistributedRateLimiter.Core.Model:
| Property | Default | Description |
|---|---|---|
MaxAge |
2 hours |
Rows older than this are deleted. Set to at least your longest window duration |
Interval |
30 minutes |
How often the cleanup worker runs |
The worker starts with a 30-second delay on boot so it doesn't fire immediately on every instance restart, and any exception during cleanup is caught and logged — it retries on the next interval rather than crashing.
Algorithms in depth
SlidingWindow
Buckets requests by key and second — all requests arriving within the same second increment a single shared row, then a rolling SUM counts all rows within the window. Provides true sliding enforcement — a burst of 100 requests is measured against the exact 60-second window ending now, not against a clock-aligned boundary. Concurrent requests within the same second are serialised by the database's row-level lock on the upsert, so no application-level advisory lock is needed.
new RateLimitPolicy
{
Name = "api",
Algorithm = RateLimitAlgorithm.SlidingWindow,
Limit = 100,
Window = TimeSpan.FromMinutes(1)
}
FixedWindow
Simpler and cheaper than sliding window — one row per key, reset at each window boundary. Allows a burst of up to 2 × Limit at a boundary (the last requests of one window plus the first of the next).
new RateLimitPolicy
{
Name = "api",
Algorithm = RateLimitAlgorithm.FixedWindow,
Limit = 100,
Window = TimeSpan.FromMinutes(1)
}
FixedWindow supports any positive whole-number-of-seconds window on all providers. Buckets are aligned to the Unix epoch, so a 10-second window produces buckets at
00:00:00,00:00:10,00:00:20, etc.
TokenBucket
Maintains a token count per key. Each request consumes one token. Tokens refill continuously at RefillRatePerSecond up to BucketCapacity. Allows short bursts while enforcing a long-run average rate.
new RateLimitPolicy
{
Name = "api",
Algorithm = RateLimitAlgorithm.TokenBucket,
BucketCapacity = 200, // burst up to 200
RefillRatePerSecond = 10 // steady-state: 10 req/s
}
Database providers
PostgreSQL
using DistributedRateLimiter.Providers.Postgres;
new PostgresRateLimitStore(connectionString, tableName: "__rate_limits")
EnsureSchemaAsync creates the table if it does not exist:
CREATE TABLE IF NOT EXISTS __rate_limits (
key TEXT NOT NULL,
window_start TIMESTAMPTZ NOT NULL,
count INT NOT NULL DEFAULT 0,
tokens FLOAT NULL,
last_refill TIMESTAMPTZ NULL,
PRIMARY KEY (key, window_start)
);
FixedWindow and TokenBucket use a single atomic round-trip via INSERT ... ON CONFLICT DO UPDATE ... RETURNING. SlidingWindow uses a transaction with two statements — an upsert into a second-level bucket row and a rolling SUM count query. The ON CONFLICT DO UPDATE row-level lock serialises concurrent same-second requests, so no advisory lock is needed.
Required features:
| Feature | Minimum version |
|---|---|
ON CONFLICT DO UPDATE (upsert) |
PostgreSQL 9.5 |
RETURNING clause |
PostgreSQL 8.2 |
DATE_TRUNC, clock_timestamp() |
PostgreSQL 8.1 |
No extensions or special server configuration required. Minimum: PostgreSQL 9.5. PostgreSQL 12+ recommended.
Theoretical throughput (single app instance, pool of 100 connections, formula: pool ÷ (latency × round-trips)):
| Deployment | Latency | FixedWindow / TokenBucket (1 RT) | SlidingWindow (4 RT) |
|---|---|---|---|
| Localhost / same host | ~0.2 ms | ~500,000 | ~125,000 |
| Same datacenter / LAN | ~1 ms | ~100,000 | ~25,000 |
| Cloud, same region | ~2–5 ms | ~20,000–50,000 | ~5,000–12,500 |
| Cross-region | ~20–50 ms | ~2,000–5,000 | ~500–1,250 |
Round-trip breakdown:
- FixedWindow / TokenBucket — 1 round-trip (
INSERT ... ON CONFLICT DO UPDATE ... RETURNING) - SlidingWindow — 4 round-trips (
BEGIN+ upsert + count query +COMMIT)
With multiple app instances, total connections across all instances must stay below PostgreSQL's max_connections (default 100 on most managed services). Use PgBouncer in transaction mode to scale past this limit.
Package: Npgsql 8.x+. Targets net8.0, net9.0, net10.0.
SQL Server
using DistributedRateLimiter.Providers.MsSql;
new MsSqlRateLimitStore(connectionString, tableName: "__rate_limits")
EnsureSchemaAsync creates the table if it does not exist:
IF OBJECT_ID(N'[__rate_limits]', N'U') IS NULL
CREATE TABLE [__rate_limits] (
key NVARCHAR(512) NOT NULL,
window_start DATETIMEOFFSET NOT NULL,
count INT NOT NULL DEFAULT 0,
tokens FLOAT NULL,
last_refill DATETIMEOFFSET NULL,
PRIMARY KEY (key, window_start)
);
Uses MERGE ... WITH (HOLDLOCK) for atomic upserts.
Required features:
| Feature | Minimum version | Used by |
|---|---|---|
MERGE with HOLDLOCK |
SQL Server 2008 | All algorithms |
DATETIMEOFFSET |
SQL Server 2008 | All algorithms |
OUTPUT clause on MERGE |
SQL Server 2008 | FixedWindow, TokenBucket |
SYSUTCDATETIME() |
SQL Server 2008 | All algorithms |
GREATEST() |
SQL Server 2022 | TokenBucket only |
Minimum: SQL Server 2008 for FixedWindow and SlidingWindow. SQL Server 2022+ is required for the TokenBucket algorithm due to the GREATEST() function.
Theoretical throughput (single app instance, pool of 100 connections, formula: pool ÷ (latency × round-trips)):
| Deployment | Latency | FixedWindow / TokenBucket (1 RT) | SlidingWindow (3 RT) |
|---|---|---|---|
| Localhost / same host | ~0.2 ms | ~500,000 | ~165,000 |
| Same datacenter / LAN | ~1 ms | ~100,000 | ~33,000 |
| Cloud, same region | ~2–5 ms | ~20,000–50,000 | ~7,000–17,000 |
| Cross-region | ~20–50 ms | ~2,000–5,000 | ~650–1,650 |
Round-trip breakdown:
- FixedWindow / TokenBucket — 1 round-trip (
MERGE ... OUTPUTin a single batch) - SlidingWindow — 3 round-trips (
BEGIN TX+MERGE; SELECTbatch +COMMIT)
SQL Server's default max pool size is 100 per connection string; increase via Max Pool Size=N in the connection string.
Package: Microsoft.Data.SqlClient 6.x. Targets net8.0, net9.0, net10.0.
MySQL / MariaDB
using DistributedRateLimiter.Providers.MySql;
new MySqlRateLimitStore(connectionString, tableName: "__rate_limits")
EnsureSchemaAsync creates the table if it does not exist:
CREATE TABLE IF NOT EXISTS `__rate_limits` (
`key` VARCHAR(512) NOT NULL,
`window_start` DATETIME(6) NOT NULL,
`count` INT NOT NULL DEFAULT 0,
`tokens` DOUBLE NULL,
`last_refill` DATETIME(6) NULL,
PRIMARY KEY (`key`, `window_start`)
);
MySQL/MariaDB does not support RETURNING on upserts so FixedWindow and TokenBucket cost 2 round-trips instead of 1. SlidingWindow uses a multi-statement batch (INSERT ... ON DUPLICATE KEY UPDATE + SELECT) inside a READ COMMITTED transaction — InnoDB row-level locking on the upsert serialises concurrent same-second requests without an advisory lock.
Required features:
| Feature | Minimum version | Used by |
|---|---|---|
INSERT ... ON DUPLICATE KEY UPDATE |
MySQL 4.1 / MariaDB 5.1 | All algorithms |
DATETIME(6) (microsecond precision) |
MySQL 5.6 / MariaDB 5.3 | All algorithms |
GREATEST(), LEAST() |
MySQL 5.0 / MariaDB 5.0 | TokenBucket |
| InnoDB storage engine | MySQL 5.5 (default) / MariaDB 5.5 (default) | SlidingWindow concurrency |
READ COMMITTED isolation level |
MySQL 5.0 / MariaDB 5.0 | SlidingWindow |
| Multi-statement queries | MySQL 5.0 / MariaDB 5.0 | SlidingWindow |
Minimum: MySQL 5.6+ or MariaDB 5.3+. InnoDB is required for SlidingWindow; MyISAM is not supported. Multi-statement queries and READ COMMITTED isolation are supported by MySqlConnector with no additional connection string options.
Theoretical throughput (single app instance, pool of 100 connections, formula: pool ÷ (latency × round-trips)):
| Deployment | Latency | FixedWindow / TokenBucket (2 RT) | SlidingWindow (3 RT) |
|---|---|---|---|
| Localhost / same host | ~0.4 ms | ~125,000 | ~83,000 |
| Same datacenter / LAN | ~2 ms | ~25,000 | ~16,000 |
| Cloud, same region | ~4–10 ms | ~5,000–12,500 | ~3,000–8,000 |
| Cross-region | ~20–50 ms | ~1,000–2,500 | ~650–1,650 |
Round-trip breakdown:
- FixedWindow / TokenBucket — 2 round-trips (upsert + select, MySQL does not support
RETURNING) - SlidingWindow — 3 round-trips (
BEGIN TX+INSERT; SELECTbatch +COMMIT)
Tune pool size via Max Pool Size=N in the connection string.
Package: MySqlConnector 2.x. Targets net8.0, net9.0, net10.0.
Custom provider
Implement IRateLimitStore from DistributedRateLimiter.Core:
using DistributedRateLimiter.Core.Interface;
using DistributedRateLimiter.Core.Model;
public sealed class MyCustomStore : IRateLimitStore
{
public Task EnsureSchemaAsync(CancellationToken ct = default) { ... }
public Task<RateLimitResult> SlidingWindowAsync(
string key, int limit, TimeSpan window, CancellationToken ct = default) { ... }
public Task<RateLimitResult> FixedWindowAsync(
string key, int limit, TimeSpan window, CancellationToken ct = default) { ... }
public Task<RateLimitResult> TokenBucketAsync(
string key, int capacity, double refillRatePerSecond, CancellationToken ct = default) { ... }
public Task PurgeExpiredAsync(TimeSpan maxAge, CancellationToken ct = default) { ... }
}
Pass it directly to AddDbRateLimiter:
builder.Services.AddDbRateLimiter(new MyCustomStore(), opts => { ... });
Configuration reference
RateLimitPolicy
| Property | Required | Description |
|---|---|---|
Name |
✓ | Unique policy identifier used in UseDbRateLimiter and CheckAsync |
Algorithm |
✓ | SlidingWindow, FixedWindow, or TokenBucket |
Limit |
Window algorithms | Maximum requests per window |
Window |
Window algorithms | Duration of the window |
BucketCapacity |
TokenBucket |
Maximum tokens (burst ceiling) |
RefillRatePerSecond |
TokenBucket |
Tokens added per second |
RateLimitCleanupOptions
| Property | Default | Description |
|---|---|---|
StartupStagger |
30 seconds |
Delay after application startup before the first cleanup run |
MaxAge |
2 hours |
Minimum age of a row before it is eligible for deletion |
Interval |
30 minutes |
How often the cleanup worker runs |
When to use this vs Redis
A database-backed rate limiter is a good fit when:
- Your stack does not already include Redis
- You need distributed enforcement across instances without adding infrastructure
- You are already on a supported database for other purposes
Throughput guide for a single hot key (cloud, same region):
| Algorithm | Per-key limit | Why | |
|---|---|---|---|
| FixedWindow / TokenBucket | ~10,000–50,000 req/s | Single atomic statement, no serialisation | |
| SlidingWindow | ~5,000–12,500 req/s | Pool-bound (same as tables above); row-level lock held only during the upsert, not the full transaction | } |
For most real-world rate limit keys (per-user, per-IP, per-tenant) traffic rarely exceeds a few hundred requests per second on any single key, so all three algorithms are a practical fit. SlidingWindow becomes the bottleneck only when a single key is genuinely a hot path — in that case, switch to FixedWindow or TokenBucket, or use Redis.
Consider Redis when:
- A single key routinely receives thousands of requests per second and you need SlidingWindow semantics
- You need sub-millisecond enforcement latency
- You are already running Redis for caching or pub/sub
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- DistributedRateLimiter.Core (>= 1.0.3)
-
net8.0
- DistributedRateLimiter.Core (>= 1.0.3)
-
net9.0
- DistributedRateLimiter.Core (>= 1.0.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
See https://github.com/JayArrowz/DistributedRateLimiter/releases for release notes.