How to rate limit RemoteEvents in Roblox

A remote that spawns parts, plays effects or calls data stores can be fired far faster than any button. How to add a per-player cooldown on the server.

warningLuaudit check: missing-rate-limit

What it looks like

A handler does something costly or visible to everyone each time it's fired, and nothing limits how often one player can fire it. Typical work: creating parts or models, cloning, playing effects for all players, data store calls, pathfinding or raycasts over large areas.

Luau
remote.OnServerEvent:Connect(function(player)
	local pet = Instance.new("Part")
	pet.Parent = workspace
end)

How to detect it

  • For each handler, ask what one call costs and whether it leaves anything behind: new instances, table entries, scheduled threads, network traffic to other players or requests to Roblox services.
  • Look for a per-player cooldown (a table keyed by player storing os.clock()), a cap on how many things a player can own, or a "busy" flag that blocks repeats.
  • Check that per-player tables are cleared in Players.PlayerRemoving, or they grow for the life of the server.
  • Cooldowns on the client don't count. An exploiter can fire the remote directly.

What an exploiter could do

They can fire the remote as fast as their client allows. If each call creates something or does heavy work, the server slows down for everyone in it, and things that are never cleaned up pile up until the server is unplayable.

How to fix it

  • Keep the last time each player used the remote and ignore calls inside the cooldown. Use os.clock() on the server.
  • Cap what one player can own at once, for example one pet each, and clean up the old one before making a new one.
  • Clear per-player entries in Players.PlayerRemoving.

Vulnerable example

Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage")

ReplicatedStorage.Remotes.SpawnPet.OnServerEvent:Connect(function(player: Player)
	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if not root or not root:IsA("BasePart") then
		return
	end
	local pet = Instance.new("Part")
	pet.Name = player.Name .. "Pet"
	pet.Size = Vector3.new(1, 1, 1)
	pet.CFrame = root.CFrame * CFrame.new(2, 1, 0)
	pet.Parent = workspace
end)

Fixed example

Fixed
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local cooldown = 3
local lastSpawn: { [Player]: number } = {}
local pets: { [Player]: Part } = {}

ReplicatedStorage.Remotes.SpawnPet.OnServerEvent:Connect(function(player: Player)
	local now = os.clock()
	if now - (lastSpawn[player] or 0) < cooldown then
		return
	end
	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if not root or not root:IsA("BasePart") then
		return
	end
	lastSpawn[player] = now
	if pets[player] then
		pets[player]:Destroy()
	end
	local pet = Instance.new("Part")
	pet.Name = player.Name .. "Pet"
	pet.Size = Vector3.new(1, 1, 1)
	pet.CFrame = root.CFrame * CFrame.new(2, 1, 0)
	pet.Parent = workspace
	pets[player] = pet
end)

Players.PlayerRemoving:Connect(function(player)
	if pets[player] then
		pets[player]:Destroy()
	end
	pets[player] = nil
	lastSpawn[player] = nil
end)

Common false positives

  • Handlers that only read or change a small value on the firing player's own data, with nothing created and nothing sent to others, are cheap to repeat. A missing rate limit there is not worth a finding.
  • A "busy" flag or pending-request table that blocks a second call until the first finishes already limits the rate.
  • Cooldowns enforced inside a shared helper module count. Read it before reporting.