Why remote cooldowns should be per player
A single cooldown or busy flag for the whole server lets one player lock a feature for everyone. How to key cooldowns by player and share fairly.
What it looks like
A remote's cooldown or "busy" flag is a single value for the whole server instead of one per player. Whoever fires it first starts the cooldown for everyone.
Luau
local lastUsed = 0 remote.OnServerEvent:Connect(function(player) if os.clock() - lastUsed < 10 then return end lastUsed = os.clock() end)
How to detect it
- For each cooldown, debounce or busy flag on a remote handler, check what it's keyed by. A plain variable outside the handler is shared by every player.
- Touch debounces copied into remote handlers are a common source.
- Some features really are server-wide, like a single elevator, a server announcement or a boss that only one player can summon at a time. Those still need a per-player limit in front of the shared one, or one player can hold the feature forever.
What an exploiter could do
They fire the remote again the moment each cooldown ends, so nobody else ever gets to use the feature: a shop button, a vehicle spawner, a bell everyone hears, a server-wide event. It costs them almost nothing and the feature stops working for the whole server.
How to fix it
- Key cooldowns by player, in a table cleared in
Players.PlayerRemoving. - For a feature that must be shared, keep the shared limit but add a per-player cooldown that's longer, so one player can't take every turn. Queues or taking turns work too.
Vulnerable example
Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage") local cooldown = 10 local lastRing = 0 local bell = workspace.Tower.Bell ReplicatedStorage.Remotes.RingBell.OnServerEvent:Connect(function(player: Player) local now = os.clock() if now - lastRing < cooldown then return end lastRing = now local sound = bell:FindFirstChildOfClass("Sound") if sound then sound:Play() end end)
Fixed example
Fixed
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local cooldown = 10 local playerCooldown = 60 local lastRing = 0 local lastByPlayer: { [Player]: number } = {} local bell = workspace.Tower.Bell ReplicatedStorage.Remotes.RingBell.OnServerEvent:Connect(function(player: Player) local now = os.clock() if now - lastRing < cooldown or now - (lastByPlayer[player] or -playerCooldown) < playerCooldown then return end lastRing = now lastByPlayer[player] = now local sound = bell:FindFirstChildOfClass("Sound") if sound then sound:Play() end end) Players.PlayerRemoving:Connect(function(player) lastByPlayer[player] = nil end)
Common false positives
- A shared limit backed by a per-player one is the fix, not the bug.
- Shared flags that only guard work the server starts itself, like a round loop, aren't reachable from a remote.
- In a game for one player or a small fixed party, a shared cooldown may be the intended design. Mention it rather than reporting it.