Roblox GuidebookKnowledge base
English ⌄

Development / ROBLOX

Roblox request rate limits: protect a server hint from repeated clicks

Build a teaching limiter for a workshop hint: four immediate attempts, then two tokens refilled per second. Explore independent player state, server time, button behavior and deterministic local checks without sending real traffic to Roblox.

Updated:

Choose a particular action to limit #

Imagine a workshop where a beginner presses “Where is the station?” to request a hint. Several quick clicks might be accidental, an attempt to get a delayed answer or an unwanted repetition. If every request triggers expensive work, a small button can create unnecessary load. First describe the server operation behind the click and why its frequency needs a limit.

We use a text hint rather than a purchase, reward grant or profile save. This is our original teaching scenario, not a feature added to the site author's games. Four attempts and two tokens per second are chosen explanatory settings. They are not Roblox platform quotas or universal recommendations. No real multiplayer load test was performed for this tutorial.

A client button does not set the server rule #

Temporarily disabling a button and showing that a response is pending can help a player understand that their request was sent. The server must still decide independently whether another request is allowed. Roblox's documentation specifically warns against relying only on a client-side rate limit.

Do not accept the client's remaining-request count or claimed previous-click time as permission. Our module stores its balance and time on the server. The client asks for a hint; the server makes its own decision. Frequency control does not turn invalid parameters into valid ones. Type checks, allowed actions and gameplay-state validation remain separate requirements.

A token bucket allows a short burst #

Imagine each player has a bucket holding at most four tokens. Each attempt spends one. Tokens return at two per second, but the balance never exceeds capacity. Four immediate attempts are therefore possible, while a sustained stream must wait for refill. This is the token-bucket approach described by Roblox.

It differs from saying “four calls in each calendar second.” A short wait may return only part of a token. Until there is a whole token, another attempt is refused. We test fractional refill so that the reader can explain the waiting time rather than treating it as a random button failure.

Teaching bucket: 4 and 2/sOpen full-size image ↗
Fictional local test route. Times are relative to the start of the exercise.
TimeAttemptResult and balance
0First through fourthAllowed: 4 → 0
0FifthRefused: 0
0.25 sNew attemptRefused: 0.5
0.5 sNew attemptAllowed: 0

Give each player independent state #

When A spends all four tokens, B's next request should not be blocked because of A. Buckets are therefore keyed by the Player supplied to the server's OnServerEvent handler. Do not replace that object with a player name or identity supplied as another client argument. Otherwise, selecting someone else's bucket becomes part of the untrusted input.

Independent buckets do not cap the combined traffic from all players. Many users may spend their allowed attempts at once. Expensive actions or backend requests need separate analysis of total rate, queues and operation cost. This module contains no shared server budget, concurrent-task cap or state distributed between servers.

Understand the teaching ModuleScript #

Place HintRequestLimiter in ServerScriptService. Its new(capacity, refillPerSecond, clock) constructor sets the bucket size, refill rate and server clock function. Allow(player) returns permission and a short result code. Forget(player) deletes local state when the player leaves. The module does not access DataStore, create a RemoteEvent or send a hint on its own.

Capacity must be a whole number between 1 and 1000. Refill rate must be positive, finite and no greater than 1000. These are our example's chosen validation bounds, not platform quotas. Fractional rates are allowed. An invalid or failing clock causes refusal; moving time backward adds no tokens. All state belongs to the current server only.

os.clock has no specified absolute baseline. Compare differences rather than calendar dates. The teaching module accepts a finite negative baseline; a separate local test verifies refill with that clock.

-- Original teaching limiter for server-side hint requests.
-- Pass the Player supplied by OnServerEvent, never a client-supplied identity.
-- Frequency control does not validate permissions or grant rewards.
local Limiter = {}

local function finite(value)
    return type(value) == "number" and value == value
        and value > -math.huge and value < math.huge
end

function Limiter.new(capacity, refillPerSecond, clock)
    assert(finite(capacity) and capacity == math.floor(capacity)
        and capacity >= 1 and capacity <= 1000, "Invalid capacity")
    assert(finite(refillPerSecond) and refillPerSecond > 0
        and refillPerSecond <= 1000, "Invalid refill rate")
    if clock == nil then clock = os.clock end
    assert(type(clock) == "function", "Invalid clock")
    local buckets = {}
    local adapter = {}

    function adapter:Allow(player)
        if player == nil then return false, "MissingPlayer" end
        local ok, now = pcall(clock)
        if not ok or not finite(now) then
            return false, "InvalidClock"
        end
        local bucket = buckets[player]
        if not bucket then
            bucket = {tokens = capacity, last = now}
            buckets[player] = bucket
        elseif now < bucket.last then
            return false, "ClockWentBackwards"
        else
            bucket.tokens = math.min(capacity,
                bucket.tokens + (now - bucket.last) * refillPerSecond)
            bucket.last = now
        end
        if bucket.tokens < 1 then return false, "RateLimited" end
        bucket.tokens -= 1
        return true, "Allowed"
    end

    function adapter:Forget(player)
        buckets[player] = nil
    end
    return adapter
end

return Limiter

Connect the server handler deliberately #

Create a RemoteEvent named GetHint in ReplicatedStorage and a server Script alongside the module. The Script obtains services, requires the module and creates a limiter with 4, 2 and os.clock. Its handler uses the Player provided by Roblox, checks the request and sends only predefined hint text to that player.

The example expects a TutorialStep attribute equal to 1, maintained by existing server-side tutorial logic. It does not start the tutorial or change that attribute on a client's request. Without that state, it sends no hint. A client interface is also absent: separately connect it to GetHint and its response. The Script demonstrates integration, not a tested complete game.

Server hint request pathOpen full-size image ↗
Original handler diagram: a token does not replace input and gameplay-state checks.
-- Server Script; create ReplicatedStorage.GetHint as a RemoteEvent first.
-- TutorialStep must be maintained by your existing SERVER gameplay logic.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Limiter = require(
    game:GetService("ServerScriptService"):WaitForChild("HintRequestLimiter")
)
local request = ReplicatedStorage:WaitForChild("GetHint")
assert(request:IsA("RemoteEvent"), "GetHint must be a RemoteEvent")
local limiter = Limiter.new(4, 2, os.clock)

request.OnServerEvent:Connect(function(player, hintName)
    -- Each attempt consumes quota before further processing.
    if not limiter:Allow(player) then return end
    if type(hintName) ~= "string" or #hintName > 32 then return end
    if hintName ~= "FindWorkshop" then return end
    if player:GetAttribute("TutorialStep") ~= 1 then return end
    local character = player.Character
    local humanoid = character and character:FindFirstChildOfClass("Humanoid")
    if not humanoid or humanoid.Health <= 0 then return end
    request:FireClient(player, "Hint", "Look for the workshop sign.")
end)

Players.PlayerRemoving:Connect(function(player)
    limiter:Forget(player)
end)

Define what spends an attempt #

Our handler calls Allow before validating the string. An attempt therefore spends a token even if the hint name is invalid or the player is at the wrong tutorial stage. This is intentional: the limit covers entries into the handler, rather than only hints successfully delivered. Invalid input does not get an unlimited free route into subsequent processing.

Do not automatically refund a token after every rejected request, because a stream of invalid requests could bypass that rule. Gameplay meaning still requires separate checks. A token does not establish reward entitlement, item ownership, task completion or permission to modify another player's object. A different task may need a different order or cost, explicitly defined for its purpose.

Avoid answering every rejected repeat #

The teaching handler silently stops a rate-limited request. Sending an answer or writing a detailed log line for every refusal could create another stream of work on the rejection path. Do not let a limiter produce endless notifications. For observation, choose a bounded way to aggregate refusals instead of saving every incoming payload.

Silent refusal means the interface must restore its button after a sensible local wait and explain that the user can try again. We do not implement that client timer here. Never leave the button disabled forever while waiting for an answer the server intentionally does not send. Keep interface convenience separate from the server's permission decision.

Test with a controlled clock #

The local Luau test injects its own clock function. At time 0, A makes four allowed attempts; a fifth returns RateLimited. At 0.25, only half a token has accumulated, so another attempt is refused. At 0.5, one token is available: one attempt succeeds and the next fails. These numbers describe a local test, not visits to a real game.

The test also checks B's independent bucket. A long idle period restores no more than four attempts. Invalid time and backward movement do not create extra balance. Constructor validation, cleanup and fractional refill rates are covered. All checks use substitute objects and controlled time, without real Roblox remotes, players or server-performance measurements.

CheckExpected outcome
Immediate fifth attemptRateLimited
Another player requestsIndependent bucket
Long idle periodAt most 4 tokens
Clock moves backwardNo refill
Invalid clockInvalidClock
State after cleanupNew full bucket

Clean up when a player leaves #

Connect Players.PlayerRemoving to limiter:Forget(player). Otherwise the table retains departed players' state until the server ends. Do not call Forget after an ordinary refusal: the following request would receive a fresh full bucket, undoing the limit. Cleanup belongs to player lifecycle management, not to restoring a button's permission.

A new entry after cleanup starts a new bucket. That is expected local behavior, not protection against reconnecting and not an account-wide limit. State is not persistent between servers. Requirements spanning different sessions need their own design; deleting a table entry does not prove those guarantees.

Frequency limits do not bound all downstream work #

An allowed request might start a long-running task elsewhere. Our module neither waits for completion nor counts tasks already running. Even the permitted rate can be excessive for an expensive operation. Inspect what happens after allowance: are large models cloned, API calls started or other players affected?

Purchases, data saving and a shared economy require their own system rules rather than tokens alone. This example also performs no distance check against a physical station: its hint is deliberately a text action with no such requirement. When adapting it to an object interaction, add the needed server checks for position, permissions and state. We changed no existing owner-game code.

Prepare the development handoff #

Keep the limited action, the meaning of spending an attempt, capacity, refill rate and required server validations together. Add the local test routes and explicitly mark real network and load tests as pending. Do not pass four as a universal setting for every button or weapon; it belongs only to this exercise.

Ask the developer to check two players, a long idle period, departure and invalid requests, plus button recovery after silent refusal. List the absent features separately: shared server budget, concurrent operations, cross-session state and a client interface. This makes the idea reusable in the next game without obscuring how much is actually ready.

Original sources

Roblox Creator Hub — Client-server boundary
Luau — Standard library
Roblox Creator Hub — os