chi0sk
github.com/chi0sk

RateLimiter v3.1

Production-grade distributed rate limiting for Roblox. Four algorithms, G-Counter CRDT token bucket, ring-buffer sliding window, vector clock causality, and idempotent delta replication across servers.

Four Strategies

Sliding window, token bucket, fixed window, and leaky bucket - each configurable per endpoint

CRDT Token Bucket

G-Counter CRDT with idempotent max-merge: mathematically sound cross-server token accounting

Ring Buffer

O(1) amortized sliding window with head/tail pointers - no allocations per check

Vector Clocks

Causal ordering with per-bucket staleness checks - stale updates are discarded, not applied

Penalty Replication

Penalties sync across servers - users cannot bypass bans by switching server

Idempotent Delivery

UUID dedup store prevents double-application of redelivered MessagingService messages

Why RateLimiter?

Most Roblox rate limiters are single-server only, use process-uptime timestamps, or bolt on "distributed" mode by injecting synthetic timestamps into shared state. This one is built differently:

  • Wall clock time: DateTime.now().UnixTimestampMillis - comparable across servers
  • Real CRDT: Token bucket is a G-Counter - merge is idempotent, commutative, and associative by construction
  • Actual causality: happens_before is used, not just implemented for decoration
  • Honest about limits: AP system, not CP - see Distributed Limitations
AP System: RateLimiter provides Availability and Partition Tolerance. It is eventually consistent, not strongly consistent. Do not use it as the sole guard for irreversible economy actions. See Limitations.

What's New in v3.1

v3.1 is a targeted fix pass on v3.0. No architectural changes - only correctness and precision improvements.

Vector Clock Snapshot on Publish

Outbound messages now carry a shallow copy of the vector clock at send time. Previously, the live reference was embedded - any clock increment between batch construction and PublishAsync completing would corrupt the snapshot non-deterministically.

Per-Bucket Vector Clock Staleness Check

Before applying each incoming delta, the system now checks happens_before(update.vector_clock, bucket.vector_clock) against the bucket's clock, not just the cluster clock. A causally old update that isn't dominated at the cluster level but is dominated per-bucket is now correctly skipped.

SyncIntervalSeconds at Limiter Level

Previously configurable only per-limit. Now also configurable on the limiter itself as a global default. The sync loop uses max(sync_interval, backoff) so backoff still overrides it correctly on failure.

MaxServerRequestsPerUser (renamed)

Renamed from MaxGlobalRequestsPerUser. The old name implied cross-cluster aggregation, which was never true. This ceiling is per-server only.

Breaking Changes

One rename: MaxGlobalRequestsPerUserMaxServerRequestsPerUser. Everything else is backward compatible.

Getting Started

Installation

Place RateLimiter.lua in ReplicatedStorage. Require it from a Script in ServerScriptService. RateLimiter is server-only - it will assert if required from the client.

Basic Setup

local RateLimiter = require(game.ReplicatedStorage.RateLimiter)

local limiter = RateLimiter.new({
    EnableDistributed = true,         -- sync across servers via MessagingService
    MaxServerRequestsPerUser = 1000,  -- per-server ceiling across all endpoints
    SyncIntervalSeconds = 5,          -- base distributed sync interval
})

-- define a limit
limiter:CreateLimit("chat", {
    MaxRequests = 5,
    WindowSeconds = 10,
    Strategy = "sliding_window",
    BurstAllowance = 1.0,  -- no burst for chat
    ViolationThreshold = 3,
    PenaltyDurationSeconds = 30,
    OnViolation = function(userId, key, info)
        warn("Violation:", userId, "count:", info.ViolationCount)
    end,
})

-- check on each player action
game.Players.PlayerAdded:Connect(function(player)
    -- hook to your RemoteEvent or action
    local result = limiter:Check(player.UserId, "chat")

    if result.Allowed then
        -- proceed
    else
        print("Rate limited. Retry after:", result.RetryAfterMs, "ms")
    end
end)

Single-Server Setup

If your game runs on one server, disable distributed mode entirely - there is no benefit and it adds MessagingService overhead.

local limiter = RateLimiter.new({
    EnableDistributed = false,
})

Strategies

Each limit can use a different algorithm. Choose based on the traffic shape you want to enforce.

Sliding Window (Default)

Tracks the exact timestamp of every request in a ring buffer. On each check, expired entries are evicted from the front. Most accurate, because there are no window-boundary artifacts.

limiter:CreateLimit("chat", {
    MaxRequests = 5,
    WindowSeconds = 10,
    Strategy = "sliding_window",
    BurstAllowance = 1.0,  -- 1.0 = no burst, 1.2 = 20% burst headroom
})

Characteristics

  • O(1) amortized push/pop via ring buffer
  • No GC pressure - no table allocations on each check
  • Accurate to the millisecond
  • In distributed mode: per-server enforcement only (no cross-server merging)

Best For

  • Chat, RemoteEvent spam prevention
  • Any endpoint where accuracy matters more than cross-server strictness
Distributed note: Sliding window does not merge across servers. A user hitting N servers in parallel can consume up to N × limit per window. For cross-server strictness, use token bucket.

Token Bucket

Tokens refill at a constant rate up to a configurable capacity. Allows bursts up to BurstAllowance × MaxRequests. In distributed mode, this strategy uses a proper G-Counter CRDT - cross-server consumption is correctly accounted for.

limiter:CreateLimit("trade", {
    MaxRequests = 10,
    WindowSeconds = 60,
    Strategy = "token_bucket",
    BurstAllowance = 1.0,   -- no burst for economy
    CostPerRequest = 1,     -- default cost; override per-check for variable cost
})

Variable Cost

Pass a cost argument to Check() to consume more than one token per request:

-- cheap endpoint: 1 token
limiter:Check(userId, "api", 1)

-- expensive endpoint: 10 tokens
limiter:Check(userId, "api", 10)

How the CRDT Works

  • consumed[server_id] - monotonically increasing per server
  • Merge: max(local, remote) per server entry - idempotent, commutative, associative
  • tokens_earned = min(capacity, elapsed_since_epoch × refill_rate) - deterministic from wall clock
  • available = tokens_earned − sum(all consumed)
  • Bucket starts full - first request is never blocked on cold start

Best For

  • Economy actions, trading, DataStore writes - anywhere cross-server convergence matters
  • Variable-cost endpoints where heavier operations consume more quota

Fixed Window

Resets a counter at deterministic boundaries: floor(now / window_ms) × window_ms. Fast and cheap. Has the classical boundary-burst flaw - a user can consume 2× the limit by hitting the end of one window and the start of the next.

limiter:CreateLimit("analytics", {
    MaxRequests = 100,
    WindowSeconds = 60,
    Strategy = "fixed_window",
})

Best For

  • Non-critical endpoints where the boundary-burst flaw is acceptable
  • Analytics, logging, low-stakes rate gating

Leaky Bucket

Requests fill a queue that drains at a constant rate. Provides smooth, shaped output. If the queue is full, the request is rejected.

limiter:CreateLimit("remoteEvent", {
    MaxRequests = 30,
    WindowSeconds = 10,
    Strategy = "leaky_bucket",
})

Best For

  • Traffic shaping where you want a smooth constant output rate
  • Preventing burst-induced server spikes

Strategy Comparison

Strategy Accuracy Distributed Burst Support Best For
Sliding Window Highest Per-server only Via BurstAllowance Chat, spam prevention
Token Bucket High CRDT convergent Yes (configurable) Economy, APIs, trading
Fixed Window Lower (boundary flaw) Delta replicated No Analytics, low-stakes
Leaky Bucket High Delta replicated No (queue-based) Traffic shaping

Distributed Mode

RateLimiter uses MessagingService to propagate state changes across servers. Each server maintains its own local buckets and exchanges updates periodically.

G-Counter CRDT (Token Bucket)

Token bucket state is a proper state-based G-Counter CRDT. This means the distributed merge is mathematically sound:

-- Each server tracks only its own consumption, monotonically
state.consumed[SERVER_ID] += cost

-- Remote merge: take max per server entry (idempotent)
state.consumed[remote_id] = math.max(local_consumed, remote_consumed)

-- Available tokens is deterministic on all servers
local tokens_earned = math.min(capacity, elapsed_since_epoch * refill_rate)
local available = tokens_earned - sum(state.consumed)

Because merge is idempotent, redelivered messages do not cause double-counting even without the dedup layer (though dedup is still present as a defense-in-depth measure).

Vector Clocks & Causality

Every bucket carries a vector clock. Incoming updates are subject to two staleness checks before being applied:

  1. Cluster-level check: If the update's vector clock is strictly dominated by the cluster's current clock, the entire batch is discarded.
  2. Per-bucket check: Before applying each delta, the update's clock is compared against that specific bucket's clock. If it's causally dominated, the delta is skipped.
-- cluster-level guard
if VectorClockImpl.happens_before(update.vector_clock, self._vector_clock) then
    return  -- entire batch is stale
end

-- per-bucket guard (before merging)
if VectorClockImpl.happens_before(update.vector_clock, bucket.vector_clock) then
    continue  -- this specific delta is stale
end

Idempotent Delivery

Every outbound message carries a UUID (update_id). A dedup store keyed by ID is maintained on each server and pruned every 5 minutes. Redelivered messages are silently discarded.

Penalty Replication

When a user enters a penalty box, penalty_until_ms is included in the next distributed sync. Receiving servers apply max(local, remote) - the stricter expiry always wins. A user cannot escape a penalty by switching servers.

Limitations

Not suitable for hard financial invariants. This is an eventually consistent AP system. It cannot guarantee strict global enforcement during the sync window. Do not use it as the sole guard for purchases, currency grants, or other irreversible economic actions. Use a DataStore transaction or a centralized authority for those.
  • Sliding window: No cross-server merging. A user hitting N servers in parallel can consume up to N × limit per window before convergence. For strict cross-server enforcement, use token bucket or a central authority.
  • MaxServerRequestsPerUser: Per-server ceiling only, not synchronized across the cluster. A user hitting multiple servers simultaneously can exceed it across servers.
  • Sync latency: Default 5 seconds. During this window, each server enforces independently up to its local limit. Lower SyncIntervalSeconds to reduce the window at the cost of more MessagingService publishes.
  • Clock skew: DateTime.now().UnixTimestampMillis is not tightly synchronized across Roblox servers. A server running a few seconds fast will earn tokens slightly faster. This cannot be corrected without a trusted monotonic time source that Roblox does not expose.

What Distributed Mode Protects Against

  • Honest overload and accidental spam
  • Mild abuse across multiple servers (token bucket CRDT convergence)
  • Server-hop penalty bypass (penalty replication)
  • MessagingService redelivery double-counts (UUID dedup)

What It Does Not Protect Against

  • Coordinated flooding within the sync window across N servers simultaneously
  • Time skew exploitation
  • Strict global sliding window enforcement

API Reference

RateLimiter.new()

RateLimiter.new(config?: RateLimiterConfig) → RateLimiter

Creates a new rate limiter instance. Must be called from a server Script.

Field Type Default Description
EnableDistributed boolean true Enable cross-server sync via MessagingService
DefaultStrategy RateLimitStrategy "sliding_window" Algorithm used when Strategy is not specified per-limit
SyncIntervalSeconds number 5 Base interval between distributed syncs. Backoff overrides this on failure.
CleanupIntervalSeconds number 30 Seconds between cleanup passes for expired buckets
CompactionThreshold number 10000 Bucket count that triggers LRU compaction
MaxServerRequestsPerUser number 1000 Per-server ceiling across all endpoints. Not globally aggregated.
EnableMetrics boolean false Enable metrics collection
MetricsIntervalSeconds number 60 Seconds between metric snapshots
OnMetrics function nil Callback receiving Metrics on each interval

CreateLimit()

limiter:CreateLimit(key: string, config: LimitConfig)

Defines a named rate limit. Must be called before Check() with the same key.

Field Type Default Description
MaxRequests number required Maximum requests per window
WindowSeconds number required Duration of the rate limit window in seconds
Strategy RateLimitStrategy DefaultStrategy "sliding_window" | "token_bucket" | "fixed_window" | "leaky_bucket"
BurstAllowance number 1.2 Burst capacity multiplier. 1.0 = no burst. 1.5 = 50% burst headroom.
CostPerRequest number 1 Default token cost. Can be overridden per-check.
ViolationThreshold number 5 Number of violations before penalty is applied
ViolationDecaySeconds number 300 Time per violation decay tick (one violation removed per tick)
PenaltyDurationSeconds number 60 Base penalty duration
PenaltyMultiplier number 2.0 Penalty duration multiplier on each threshold breach
SyncIntervalSeconds number 5 Per-limit distributed sync interval (overrides limiter default)
OnViolation function nil Called when penalty is applied: (userId, key, ViolationInfo)
OnWarning function nil Called when remaining < 20% of limit: (userId, key, remaining)

Check()

limiter:Check(userId: number, key: string, cost?: number) → LimitResult

Checks whether a user is allowed to perform an action under the named limit. Errors if the key has not been registered with CreateLimit().

Returns a LimitResult:

{
    Allowed: boolean,
    Remaining: number,          -- requests remaining in current window
    ResetAtMs: number,          -- unix timestamp (ms) when window resets
    RetryAfterMs: number?,      -- ms until retry is allowed (nil if Allowed)
    Metadata: {
        Strategy: string,
        CurrentCount: number,
        Limit: number,
        WindowStartMs: number,
        WindowEndMs: number,
        PenaltyActive: boolean,
    },
}

Reset()

limiter:Reset(userId: number, key: string)

Clears the rate limit bucket for a user/key pair. The next Check() will behave as if the user has never made a request.

GetMetrics()

limiter:GetMetrics() → Metrics

Returns current metrics. Requires EnableMetrics = true in config.

{
    TotalRequests: number,
    AllowedRequests: number,
    BlockedRequests: number,
    UniqueUsers: number,
    UniqueKeys: number,
    ApproximateMemoryMB: number,   -- heuristic estimate, not precise
    ViolationsByKey: {[string]: number},
    AverageLatencyMs: number,
}

GetBucketInfo()

limiter:GetBucketInfo(userId: number, key: string) → BucketState?

Returns internal bucket state for a user/key pair. Returns nil if no bucket exists. Useful for debugging violations, penalties, and CRDT state.

Shutdown()

limiter:Shutdown()

Flushes any pending distributed updates before the server shuts down. Call this from game:BindToClose().

game:BindToClose(function()
    limiter:Shutdown()
end)

Configuration

Recommended Setups by Game Size

Single Server / Small Game

local limiter = RateLimiter.new({
    EnableDistributed = false,  -- not needed for single server
    EnableMetrics = true,
    MaxServerRequestsPerUser = 500,
})

Multi-Server Game (10–50 servers)

local limiter = RateLimiter.new({
    EnableDistributed = true,
    SyncIntervalSeconds = 5,       -- default; lower for stricter cross-server enforcement
    MaxServerRequestsPerUser = 1000,
    EnableMetrics = true,
    CleanupIntervalSeconds = 60,
})

High-Traffic Game (50+ servers)

local limiter = RateLimiter.new({
    EnableDistributed = true,
    SyncIntervalSeconds = 10,      -- reduce messaging load
    MaxServerRequestsPerUser = 1000,
    CompactionThreshold = 5000,    -- more aggressive LRU compaction
    CleanupIntervalSeconds = 30,
    EnableMetrics = true,
    OnMetrics = function(metrics)
        if metrics.ApproximateMemoryMB > 50 then
            warn("RateLimiter memory pressure:", metrics.ApproximateMemoryMB, "MB")
        end
    end,
})

Limit Configuration Examples

Chat (Strict)

limiter:CreateLimit("chat", {
    MaxRequests = 5,
    WindowSeconds = 10,
    Strategy = "sliding_window",
    BurstAllowance = 1.0,
    ViolationThreshold = 3,
    ViolationDecaySeconds = 120,
    PenaltyDurationSeconds = 30,
})

Economy / Trading (CRDT-convergent)

limiter:CreateLimit("trade", {
    MaxRequests = 10,
    WindowSeconds = 60,
    Strategy = "token_bucket",     -- G-Counter CRDT; converges across servers
    BurstAllowance = 1.0,          -- no burst for economy
    ViolationThreshold = 3,
    PenaltyDurationSeconds = 600,  -- 10 min
})

RemoteEvent Flood Prevention

limiter:CreateLimit("remoteEvent", {
    MaxRequests = 60,
    WindowSeconds = 1,
    Strategy = "sliding_window",
    BurstAllowance = 1.5,
    ViolationThreshold = 10,
    PenaltyDurationSeconds = 5,
})

Variable-Cost API Endpoint

limiter:CreateLimit("api", {
    MaxRequests = 100,
    WindowSeconds = 60,
    Strategy = "token_bucket",
    BurstAllowance = 1.5,
    CostPerRequest = 1,  -- default; override at Check() time
})

Best Practices

1. Use Token Bucket for Economy

Token bucket is the only strategy with mathematically convergent cross-server enforcement. Use it for any endpoint where a user exceeding the limit across servers is harmful.

-- trading, purchases, currency grants
limiter:CreateLimit("economy", {
    Strategy = "token_bucket",
    BurstAllowance = 1.0,  -- no burst
    MaxRequests = 5,
    WindowSeconds = 60,
})

2. Do Not Use RateLimiter Alone for Financial Invariants

This is an AP system. It is eventually consistent. For irreversible actions like purchases or currency grants, always pair it with a DataStore transaction or a server-authoritative check:

-- rate limit first (fast reject)
local result = limiter:Check(userId, "purchase")
if not result.Allowed then return end

-- then do the transactional check (authoritative)
DataStore:UpdateAsync(userId, function(data)
    if data.LastPurchase and os.time() - data.LastPurchase < 60 then
        return nil  -- abort
    end
    data.LastPurchase = os.time()
    return data
end)

3. Call Shutdown() on Server Close

game:BindToClose(function()
    limiter:Shutdown()
end)

4. Monitor Violations and Metrics

local limiter = RateLimiter.new({
    EnableMetrics = true,
    OnMetrics = function(metrics)
        -- surface to your observability system
        print("Blocked rate:", metrics.BlockedRequests / metrics.TotalRequests)
        print("Violations:", metrics.ViolationsByKey)
    end,
})

limiter:CreateLimit("chat", {
    OnViolation = function(userId, key, info)
        warn("Penalty applied:", userId, "violations:", info.ViolationCount)
        -- optionally: flag the user, log to DataStore, etc.
    end,
    OnWarning = function(userId, key, remaining)
        -- user is above 80% of limit, warn them if appropriate
    end,
})

5. Tune Penalty Multiplier Carefully

With PenaltyMultiplier = 2.0 (default), the penalty duration doubles on each threshold breach. If a user repeatedly hits the limit immediately after each penalty expires, the penalty grows quickly. Make sure this matches your intent:

-- chat spam: escalating penalty is desirable
limiter:CreateLimit("chat", {
    PenaltyDurationSeconds = 30,
    PenaltyMultiplier = 2.0,  -- 30s → 60s → 120s...
})

-- economy: flat penalty, no escalation
limiter:CreateLimit("trade", {
    PenaltyDurationSeconds = 600,
    PenaltyMultiplier = 1.0,  -- always exactly 10 minutes
})

Examples

Chat System with Mute

local Players = game:GetService("Players")
local limiter = RateLimiter.new({ EnableDistributed = true })

limiter:CreateLimit("chat", {
    MaxRequests = 5,
    WindowSeconds = 10,
    Strategy = "sliding_window",
    BurstAllowance = 1.0,
    ViolationThreshold = 3,
    PenaltyDurationSeconds = 30,
    OnViolation = function(userId, key, info)
        local player = Players:GetPlayerByUserId(userId)
        if player then
            player:SetAttribute("Muted", true)
            task.delay(30, function()
                if player and player.Parent then
                    player:SetAttribute("Muted", false)
                end
            end)
        end
    end,
})

-- hook to your chat RemoteEvent
ChatRemote.OnServerEvent:Connect(function(player, message)
    if player:GetAttribute("Muted") then return end

    local result = limiter:Check(player.UserId, "chat")
    if not result.Allowed then
        -- optionally inform the player
        return
    end

    -- process chat
end)

Trading System

local limiter = RateLimiter.new({
    EnableDistributed = true,
    SyncIntervalSeconds = 3,  -- tighter sync for economy
})

limiter:CreateLimit("trade", {
    MaxRequests = 5,
    WindowSeconds = 60,
    Strategy = "token_bucket",   -- G-Counter CRDT cross-server convergence
    BurstAllowance = 1.0,
    ViolationThreshold = 2,
    PenaltyDurationSeconds = 300,
    OnViolation = function(userId, key, info)
        warn("Trade rate limit violation:", userId)
    end,
})

TradeRemote.OnServerEvent:Connect(function(player, tradeData)
    local result = limiter:Check(player.UserId, "trade")
    if not result.Allowed then
        -- inform player
        return
    end

    -- process trade
end)

Remote Event Flood Guard

local limiter = RateLimiter.new({ EnableDistributed = false })

-- 60 requests per second max, burst to 90
limiter:CreateLimit("remote", {
    MaxRequests = 60,
    WindowSeconds = 1,
    Strategy = "sliding_window",
    BurstAllowance = 1.5,
    ViolationThreshold = 5,
    PenaltyDurationSeconds = 10,
})

MyRemote.OnServerEvent:Connect(function(player, ...)
    local result = limiter:Check(player.UserId, "remote")
    if not result.Allowed then return end
    -- handle event
end)

Variable-Cost API

limiter:CreateLimit("api", {
    MaxRequests = 100,
    WindowSeconds = 60,
    Strategy = "token_bucket",
    BurstAllowance = 1.5,
})

-- cheap read endpoint
local r1 = limiter:Check(userId, "api", 1)

-- expensive write endpoint
local r2 = limiter:Check(userId, "api", 10)

-- very expensive batch endpoint
local r3 = limiter:Check(userId, "api", 25)

Metrics Monitoring

local limiter = RateLimiter.new({
    EnableDistributed = true,
    EnableMetrics = true,
    MetricsIntervalSeconds = 30,
    OnMetrics = function(m)
        local block_rate = m.TotalRequests > 0
            and (m.BlockedRequests / m.TotalRequests * 100)
            or 0

        print(string.format(
            "[RateLimit] total=%d allowed=%d blocked=%d (%.1f%%) users=%d mem=%.2fMB latency=%.3fms",
            m.TotalRequests, m.AllowedRequests, m.BlockedRequests,
            block_rate, m.UniqueUsers, m.ApproximateMemoryMB, m.AverageLatencyMs
        ))
    end,
})

Performance

Algorithmic Complexity

Strategy Check Complexity Memory per Bucket Notes
Sliding Window O(1) amortized Ring buffer (8 bytes/entry) Eviction is O(k) worst case but amortized O(1)
Token Bucket O(n) where n = server count One number per known server n is typically 1–3 in distributed mode
Fixed Window O(1) Two numbers Cheapest per check
Leaky Bucket O(1) Two numbers Same as fixed window

MessagingService Budget

Servers Sync Interval Publishes/Min vs Limit (150/min)
10 5s ~12 8%
50 5s ~60 40%
100 5s ~120 80% (backoff will kick in)
100 10s ~60 40%

Exponential backoff (1s → 30s max) activates automatically if MessagingService rate is exceeded. Failed batches are re-queued and retried.

Memory Usage

Approximate memory per bucket: ~250 bytes (ring buffer metadata + bucket struct). Actual memory depends on how many timestamps the sliding window ring is holding at a given moment (~8 bytes each). Memory estimation is exposed in GetMetrics().ApproximateMemoryMB - this is a heuristic, not a precise measurement.

LRU compaction removes the oldest 10% of buckets when the count exceeds CompactionThreshold. Inactive buckets are also pruned on each cleanup pass.

v1.0 → v3.1 Changes

Issue v1.0 v3.1
Time source os.clock() (process uptime) DateTime.now() (wall clock ms)
Sliding window O(k) table allocation per check O(1) amortized ring buffer
Token burst BurstAllowance ignored Correctly applied to capacity
Distributed Synthetic timestamp injection G-Counter CRDT with vector clocks
Message idempotency None UUID dedup with TTL pruning
Penalty replication Local only Synced via max-merge
Vector clock usage Decorative Cluster + per-bucket staleness checks

Troubleshooting

Users Blocked Too Aggressively

  • Increase MaxRequests or BurstAllowance
  • Increase ViolationThreshold so penalties apply less often
  • Increase ViolationDecaySeconds for faster forgiveness
  • Lower PenaltyMultiplier to 1.0 to prevent escalation

Users Bypassing Limits Across Servers

  • Switch to Strategy = "token_bucket" - the only strategy with cross-server CRDT convergence
  • Lower SyncIntervalSeconds to reduce the enforcement window (at the cost of more messaging publishes)
  • For strict enforcement, pair with a DataStore transaction

MessagingService Errors

If you see RateLimiter: messaging rate limit reached, backing off:

  • Increase SyncIntervalSeconds (e.g., 10–15 for large games)
  • The system self-recovers via exponential backoff (1s → 30s)
  • Failed updates are re-queued and sent on the next successful publish

High Memory Usage

  • Lower CleanupIntervalSeconds so inactive buckets are removed sooner
  • Lower CompactionThreshold to trigger LRU compaction earlier
  • Check GetMetrics().ApproximateMemoryMB - if it is consistently high, you may have a large active user base; this is expected

Debugging a Specific User

local bucket = limiter:GetBucketInfo(userId, "chat")

if bucket then
    print("Strategy:", bucket.strategy)
    print("Violations:", bucket.violations)
    print("Penalty until:", bucket.penalty_until_ms)
    
    -- for token bucket: inspect CRDT state
    if bucket.strategy == "token_bucket" then
        local consumed_total = 0
        for server_id, v in pairs(bucket.state.consumed) do
            print("  consumed by", server_id, ":", v)
            consumed_total += v
        end
        print("Total consumed:", consumed_total)
    end
end

Getting Help

  • Enable EnableMetrics = true and use OnMetrics to surface data
  • Use GetBucketInfo() to inspect internal state per user
  • Contact @chi0sk on Discord

RateLimiter v3.1 is created and maintained by sam (@chi0sk)

Documentation last updated: February 2026

GitHub | License (GPL-3.0)