Skip to content
Engine

Async and Events

RateLimiter

Token buckets, sliding and fixed windows, leaky buckets, cooldowns, debounce and throttle, all keyed per player or per anything.

PackageShared

Getting it

local RateLimiter = Engine:GetPackage("RateLimiter")

Overview

RateLimiter answers one question: "may this happen right now?" Every limiter tracks usage per key. Pass a player's UserId as the key and each player gets their own budget; leave the key out and all calls share one budget.

Limit how often a player can fire a remote
local RateLimiter = Engine:GetPackage("RateLimiter")

-- 5 requests per second on average, bursts of up to 10
local limiter = RateLimiter.TokenBucket(5, 10)

remote.OnServerEvent:Connect(function(player, ...)
	local allowed, retryAfter = limiter:Allow(player.UserId)
	if not allowed then
		warn(player.Name, "is sending too fast, retry in", retryAfter)
		return
	end
	handleRequest(player, ...)
end)

Players.PlayerRemoving:Connect(function(player)
	limiter:Reset(player.UserId)
end)

Which limiter to pick

LimiterBehaviourGood for
TokenBucketRefills steadily, allows short bursts.Remotes, chat, most player actions.
SlidingWindowExact count over the last N seconds.Strict caps such as "3 reports per minute".
SlidingWindowCounterClose estimate of a sliding window using two counters.High-volume keys where memory matters.
FixedWindowCount resets at fixed boundaries."100 per hour" quotas, simple to explain to players.
LeakyBucketDrains at a steady rate, no bursts above capacity.Smoothing a steady flow.
CooldownOne use, then wait.Abilities, emotes, buttons.
GroupSeveral limiters at once, all must allow.Combining a burst limit with an hourly quota.
Debounce / ThrottleWrap a function instead of asking.Search boxes, resize handlers, saving.

Shared limiter API

All limiters (everything except Debounce and Throttle) have these members.

Allow

limiter:Allow(key: Key?, cost: number?): (boolean, number?)

Tries to spend cost (default 1) for key. Returns true when allowed. When not allowed it returns false plus the number of seconds after which the same call would succeed, and fires Limited.

Refund

limiter:Refund(key: Key?, cost: number?)

Gives back cost that was spent, for example when the action you allowed failed for another reason.

Reset

limiter:Reset(key: Key?)

Forgets the state of one key. Without a key it forgets every key.

Prune

limiter:Prune(maxIdleSeconds: number?): number

Drops keys that have not been used for maxIdleSeconds (default from configuration) and returns how many were dropped. Limiters also prune themselves every few thousand calls, so long-running servers do not collect stale keys.

Limited

limiter.Limited: Signal<(key: Key?, cost: number)>

Fires every time a call is refused. Handy for logging abuse.

Limiters

TokenBucket

RateLimiter.TokenBucket(ratePerSec: number, burst: number, clock: Clock?): TokenBucketLimiter

Each key holds up to burst tokens and gains ratePerSec tokens per second. A call spends cost tokens.

Extra memberDescription
Rate: numberTokens per second. Can be changed at any time; must be positive.
Burst: numberBucket size. Can be changed at any time; must be positive.
:Available(key?): numberTokens the key has right now.

SlidingWindow

RateLimiter.SlidingWindow(limit: number, windowSec: number, clock: Clock?): SlidingWindowLimiter

Allows at most limit cost within any windowSec long stretch. It remembers every call in the window, so it is exact.

Extra memberDescription
Limit: number / Window: numberSettable.
:Count(key?): numberCost used inside the current window.

SlidingWindowCounter

RateLimiter.SlidingWindowCounter(limit: number, windowSec: number, clock: Clock?): SlidingWindowCounterLimiter

Like SlidingWindow, but stores only the count of the current and the previous window and weighs them, so memory use is constant per key. Same extra members as SlidingWindow.

FixedWindow

RateLimiter.FixedWindow(limit: number, windowSec: number, clock: Clock?): FixedWindowLimiter

Allows limit cost per window; the count resets when the clock crosses into the next window.

Extra memberDescription
Limit: number / Window: numberSettable.
:Remaining(key?): numberCost left in the current window.
:ResetsIn(key?): numberSeconds until the window resets.

LeakyBucket

RateLimiter.LeakyBucket(ratePerSec: number, capacity: number, clock: Clock?): LeakyBucketLimiter

Each call pours cost into a bucket of size capacity that drains at ratePerSec. A call that would overflow is refused.

Extra memberDescription
Rate: number / Capacity: numberSettable.
:Level(key?): numberHow full the bucket is.

Cooldown

RateLimiter.Cooldown(duration: number, clock: Clock?): CooldownGate

Allows one call, then refuses until duration seconds have passed. cost is ignored. :Refund() ends the cooldown early.

local dash = RateLimiter.Cooldown(3)

local function tryDash(player: Player)
	if dash:Allow(player.UserId) then
		performDash(player)
	end
end
Extra memberDescription
Duration: numberSettable.
:Ready(key?): booleanWhether the next call would be allowed.
:TimeLeft(key?): numberSeconds until ready, 0 when ready.

Group

RateLimiter.Group(limiters: { Limiter }): GroupLimiter

Asks each limiter in order. The call is allowed only when all of them allow it; if one refuses, the ones that already allowed are refunded and the longest retryAfter is returned. :GetLimiters() returns a copy of the list.

local chat = RateLimiter.Group({
	RateLimiter.TokenBucket(1, 5),        -- short bursts
	RateLimiter.FixedWindow(200, 3600),   -- and at most 200 messages an hour
})

Function wrappers

Debounce

RateLimiter.Debounce(fn: (...any) -> (), t: number, options: DebounceOptions?): ((...any) -> (), DebounceControl)

Returns a wrapped function. Calling it repeatedly only calls fn once things have been quiet for t seconds, with the arguments of the last call. With Leading = true, fn also runs on the first call of a burst. Defaults: Leading = false, Trailing = true.

local search, control = RateLimiter.Debounce(function(text)
	runSearch(text)
end, 0.3)

textBox:GetPropertyChangedSignal("Text"):Connect(function()
	search(textBox.Text)
end)

Throttle

RateLimiter.Throttle(fn: (...any) -> (), t: number, options: ThrottleOptions?): ((...any) -> (), ThrottleControl)

Returns a wrapped function that calls fn at most once every t seconds. With Leading (default true) the first call runs at once; with Trailing (default true) the last call made during the wait runs when the wait ends. SchedulerService:Throttle is the same thing expressed in calls per second.

Control object

Both wrappers also return a control object.

MemberDescription
Pending: booleanWhether a call is waiting (Debounce) or the wrapper is inside its wait (Throttle).
:Cancel()Drops anything waiting without calling fn.
:Flush()Calls fn right now with the waiting arguments, if any, and ends the wait.

Options for both: { Leading: boolean?, Trailing: boolean?, clock: Clock? }.

Configuration

Add a RateLimiter module to your Configuration folder to change these defaults. See Configuration.

Defaults from DefaultConfiguration/Packages/RateLimiter7 settings
SettingDefaultWhat it does
DefaultCost1Cost :Allow()/:Refund() charge when none is passed.
DefaultPruneIdleSeconds300Idle seconds after which :Prune() drops a key when none is passed.
AutoPruneEvery2000:Allow() calls between automatic prune sweeps.
DefaultDebounceLeadingfalseWhether a Debounce fires on the first call when options.Leading is omitted.
DefaultDebounceTrailingtrueWhether a Debounce fires after the last call when options.Trailing is omitted.
DefaultThrottleLeadingtrueWhether a Throttle fires on the first call when options.Leading is omitted.
DefaultThrottleTrailingtrueWhether a Throttle fires once more at the end of a window when options.Trailing is omitted.

Types

export type Key = string | number
export type Limiter
export type TokenBucketLimiter
export type SlidingWindowLimiter
export type SlidingWindowCounterLimiter
export type FixedWindowLimiter
export type LeakyBucketLimiter
export type CooldownGate
export type GroupLimiter
export type DebounceOptions
export type DebounceControl
export type ThrottleOptions
export type ThrottleControl