How to rate limit MessagingService publishes from players

One player firing a remote that publishes to a shared topic can use up its budget for every server. How to add per-player limits and validate what you receive.

warningLuaudit check: cross-server-spam

What it looks like

A remote, chat command or other player action calls MessagingService:PublishAsync with no per-player limit, often on a topic every server listens to: global announcements, cross-server trade boards, friend finders or "player X just hatched a legendary" shout-outs.

Luau
remotes.Shout.OnServerEvent:Connect(function(player, text)
	MessagingService:PublishAsync("Shouts", filtered(player, text))
end)

How to detect it

  • Find every MessagingService:PublishAsync call and trace back what triggers it. Calls a player can cause, directly or through a remote, chat, a purchase prompt or a touch, need a server-side per-player limit.
  • Roblox's limits, per the MessagingService reference: each server can send 600 + 240 × players messages a minute, and each topic can receive 40 + 80 × servers messages a minute across the whole game. One server sending fast enough can use up a shared topic's receive budget for every server.
  • Check the limit is a per-player cooldown or budget kept on the server. A cooldown on the client, or one shared by the whole server, isn't enough; see One cooldown shared by every player and Cooldown the client can reset.
  • PublishAsync is a web call and can error. Check it's in pcall and that a failure doesn't leave the player's own action half done.
  • Look at the receiving side too. SubscribeAsync callbacks get data that came from a player in another server. Treat it like remote arguments: check types and sizes, and filter text for this server's players. Unfiltered text is Player text shown to others without filtering.

What an exploiter could do

One player fires the remote as fast as they can and fills the topic. Every server's shout feed, trade board or event messages slow down or stop arriving for everyone, and other systems sharing the topic or the game's limits, such as cross-server moderation or live events, are pushed over them too. Messages carry up to 1 kB each, so a flood also costs every receiving server work.

How to fix it

  • Give each player a server-side cooldown or token bucket for every action that publishes, such as one shout a minute.
  • Batch messages that don't need to arrive at once, such as shout-outs, and publish them from one server loop.
  • Keep separate topics for separate systems so one flood doesn't use up another topic's budget, and validate everything a subscription receives. All topics still share the game-wide receive limit of 400 + 200 × servers messages a minute, so per-player limits are still needed.

Vulnerable example

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

ReplicatedStorage.Remotes.ShareFind.OnServerEvent:Connect(function(player: Player, item: unknown)
	if typeof(item) ~= "string" or #item > 30 then
		return
	end
	pcall(MessagingService.PublishAsync, MessagingService, "GlobalFinds", {
		userId = player.UserId,
		item = item,
	})
end)

Fixed example

Fixed
local MessagingService = game:GetService("MessagingService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local cooldown = 60
local lastShare: { [Player]: number } = {}

ReplicatedStorage.Remotes.ShareFind.OnServerEvent:Connect(function(player: Player, item: unknown)
	local profile = PlayerData.get(player)
	if typeof(item) ~= "string" or not profile or not table.find(profile.Inventory, item) then
		return
	end
	local now = os.clock()
	if now - (lastShare[player] or -math.huge) < cooldown then
		return
	end
	lastShare[player] = now
	pcall(MessagingService.PublishAsync, MessagingService, "GlobalFinds", {
		userId = player.UserId,
		item = item,
	})
end)

Players.PlayerRemoving:Connect(function(player)
	lastShare[player] = nil
end)

Common false positives

  • Publishes the server makes on its own schedule, such as a server list update every minute, aren't player-driven.
  • Publishing from a developer product's ProcessReceipt, such as a global "thanks for donating" message, is limited by how fast players can pay, though a cooldown is still sensible.
  • A per-player cooldown kept on the server, like the fixed example, is the fix.