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.
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.
| 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 |
| 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 |
dotnet add package DistributedRateLimiter
dotnet add package DistributedRateLimiter.Providers.Postgresusing 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();// 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.
| 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) |
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");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.
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.
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)
}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.
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
}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.
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.
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.
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 => { ... });| 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 |
| 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 |
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