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.

warningLuaudit check: shared-cooldown

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.