In short
A RemoteEvent is an object, usually kept in ReplicatedStorage, that sends messages across the client-server boundary. A LocalScript calls FireServer and the server hears it on OnServerEvent, with the sending Player added as the first argument. The server calls FireClient(player, ...) or FireAllClients(...), and LocalScripts hear it on OnClientEvent.
- Use a RemoteFunction (
InvokeServerandOnServerInvoke) only when the client needs an answer back. AvoidInvokeClient: a client that errors, leaves or never returns breaks the server's call. - Never trust what a client sends. On the server, check the type, range and ownership of every argument, the player's distance, and how often they fire.
- Functions, metatables and objects the other side cannot see do not survive the trip. An UnreliableRemoteEvent can drop or reorder messages, and drops any payload over 1,000 bytes.
On this page
Why RemoteEvents exist
Every Roblox game runs as one server plus one client per player. The server is the authority: a new object or a property change only reaches every player when the server makes it. A change made in a LocalScript stays on that one device, with a few exceptions such as the player's own character, whose movement their client controls. So when a click or a key press should change the shared world, the client has to ask the server, and when the server wants one player's screen to react, it has to tell that client. If the split between Script and LocalScript is new to you, read Script vs LocalScript vs ModuleScript first.
Roblox gives you three objects for crossing that boundary:
- RemoteEvent: a one-way message. The sender carries on straight away.
- RemoteFunction: a request that waits for a reply. The caller pauses until the other side returns a value.
- UnreliableRemoteEvent: a one-way message that may be dropped or arrive out of order, in exchange for less network overhead. It suits data that changes constantly.
| Direction | Sender calls | Receiver connects to | Receiver gets |
|---|---|---|---|
| Client to server | remote:FireServer(...) in a LocalScript | remote.OnServerEvent in a Script | The Player who fired, then your arguments |
| Server to one client | remote:FireClient(player, ...) in a Script | remote.OnClientEvent in a LocalScript | Your arguments only |
| Server to all clients | remote:FireAllClients(...) in a Script | remote.OnClientEvent in a LocalScript | Your arguments only |
| Client asks, server answers | remoteFunction:InvokeServer(...) | remoteFunction.OnServerInvoke = function(player, ...) | The Player, then your arguments. The caller gets back whatever the function returns |
Clients cannot message each other directly. To pass something from one player to the others, fire it to the server, check it there, and let the server send it on with FireClient or FireAllClients. Roblox's security guidance is that the server acts as a gatekeeper here, never a plain relay.
Where to put your remotes
A remote has to sit where both the server and the clients can see it. The usual place is ReplicatedStorage; Roblox's docs note that Workspace or a Tool can also be right in some cases. ServerStorage and ServerScriptService are server-only, so a LocalScript waiting for a remote there waits forever.
- In the Explorer, hover over ReplicatedStorage, click the + button and insert a Folder. Name it
Remotes. - Hover over
Remotes, click + and insert a RemoteEvent. Rename it to describe its purpose, for examplePaintRequest. - Repeat for each remote. This guide uses two RemoteEvents (
PaintRequestandNotify) and one RemoteFunction (RedeemCode).
ReplicatedStorage
Remotes Folder new
PaintRequest RemoteEvent new
Notify RemoteEvent new
RedeemCode RemoteFunction new
ServerScriptService
PaintServer Script new
WelcomeServer Script new
FinishAnnouncer Script new
RedeemServer Script new
RemoteGuard ModuleScript new
StarterPlayer
StarterPlayerScripts
PaintClient LocalScript new
NotifyClient LocalScript new
StarterGui
CodeGui ScreenGui new
CodeBox TextBox new
RedeemClient LocalScript new
Workspace
Paintable Folder new
FinishLine Part new
Scripts in this guide find remotes with WaitForChild. It returns at once when the remote is already there and waits when it is not, for example when a server script creates remotes while the game runs. Use one remote per kind of request: each server handler then checks one shape of arguments, which is far easier to get right than a single remote that does anything.
Pattern 1: client to server, checked by the server
The most common job: the player does something on their screen, and the server decides what happens. Here the player presses Q to pick one of four colours and clicks or taps a part to paint it. Everyone sees the new colour, because the server does the painting.
The client sends two things: which part, and a colour number. It never sends the colour itself, and in other games it should never send a price, a reward or a damage amount. A modified client can send anything, so the server keeps the real data (here, the palette) and treats the arguments as a request to check.
- In Workspace, insert a Folder named
Paintableand put a few anchored Parts in it. - Check that
ReplicatedStorage.Remoteshas a RemoteEvent namedPaintRequest. - Insert a Script in ServerScriptService named
PaintServer, and a LocalScript in StarterPlayer, StarterPlayerScripts namedPaintClient, with the code below.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local remotes = ReplicatedStorage:WaitForChild("Remotes")
local paintRequest = remotes:WaitForChild("PaintRequest") :: RemoteEvent
local paintable = workspace:WaitForChild("Paintable")
-- The server owns the colours. The client only sends a number that picks one.
local PALETTE = {
Color3.fromRGB(231, 76, 60),
Color3.fromRGB(46, 204, 113),
Color3.fromRGB(52, 152, 219),
Color3.fromRGB(241, 196, 15),
}
local MAX_DISTANCE = 30 -- studs between the character and the part
local COOLDOWN_SECONDS = 0.25
local lastPaint: { [Player]: number } = {}
-- The first parameter is always the player who fired. Roblox fills it in,
-- so it can be trusted. Everything after it came from the client and cannot.
paintRequest.OnServerEvent:Connect(function(player: Player, part: unknown, colourIndex: unknown)
-- 1. Type: a real part, not a table pretending to be one
if typeof(part) ~= "Instance" or not part:IsA("BasePart") then
return
end
-- 2. Range: a whole number from 1 to 4. NaN and infinity fail here too.
if type(colourIndex) ~= "number" or not math.isfinite(colourIndex) then
return
end
if colourIndex % 1 ~= 0 or colourIndex < 1 or colourIndex > #PALETTE then
return
end
-- 3. Allowed target: only parts in the Paintable folder, nothing else in the game
if not part:IsDescendantOf(paintable) then
return
end
-- 4. Distance: the player's character must be near the part
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
if not (root and root:IsA("BasePart")) then
return
end
if (root.Position - part.Position).Magnitude > MAX_DISTANCE then
return
end
-- 5. Rate limit: one paint per player every quarter of a second
local now = os.clock()
local last = lastPaint[player]
if last and now - last < COOLDOWN_SECONDS then
return
end
lastPaint[player] = now
part.Color = PALETTE[colourIndex]
end)
Players.PlayerRemoving:Connect(function(player: Player)
lastPaint[player] = nil
end)
Each failed check simply returns. There is no need to tell a cheating client why it was refused.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")
local remotes = ReplicatedStorage:WaitForChild("Remotes")
local paintRequest = remotes:WaitForChild("PaintRequest") :: RemoteEvent
local paintable = workspace:WaitForChild("Paintable")
local COLOUR_COUNT = 4 -- the server holds the actual colours
local selectedColour = 1
-- Only let the ray hit parts inside the Paintable folder
local rayParams = RaycastParams.new()
rayParams.FilterType = Enum.RaycastFilterType.Include
rayParams.FilterDescendantsInstances = { paintable }
UserInputService.InputBegan:Connect(function(input: InputObject, gameProcessed: boolean)
if gameProcessed then
return -- the key or click went to the chat or a button
end
if input.KeyCode == Enum.KeyCode.Q then
selectedColour = selectedColour % COLOUR_COUNT + 1
return
end
local isClick = input.UserInputType == Enum.UserInputType.MouseButton1
local isTap = input.UserInputType == Enum.UserInputType.Touch
if isClick or isTap then
local camera = workspace.CurrentCamera
local ray = camera:ScreenPointToRay(input.Position.X, input.Position.Y)
local result = workspace:Raycast(ray.Origin, ray.Direction * 500, rayParams)
if result then
-- Ask, don't tell: the server decides whether the paint happens
paintRequest:FireServer(result.Instance, selectedColour)
end
end
end)
gameProcessed is true when the engine already acted on the input, usually in the UI: a click on a button, or keys typed into a text box such as the chat bar. Checking it means typing a Q in a message does not change colour.
Pattern 2: server to one client
The server sends a message to one player with FireClient. The first argument is the Player to send it to, and the client's handler receives only the arguments after it. Use this for anything personal: a notification, a reward popup, a sound only that player should hear.
This pair shows a welcome message when a player joins. The LocalScript builds its own small banner, so there is no GUI to set up.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local notify = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("Notify") :: RemoteEvent
local function welcome(player: Player)
-- The first argument picks which client receives it. Nobody else sees this.
notify:FireClient(player, `Welcome, {player.DisplayName}! Press Q to change colour.`)
end
Players.PlayerAdded:Connect(welcome)
-- In Studio you can join before this script connects, so greet anyone already here
for _, player in Players:GetPlayers() do
welcome(player)
end
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local notify = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("Notify") :: RemoteEvent
local player = Players.LocalPlayer
-- A small banner at the top of the screen, built in code
local gui = Instance.new("ScreenGui")
gui.Name = "Notifications"
gui.ResetOnSpawn = false
gui.Parent = player:WaitForChild("PlayerGui")
local label = Instance.new("TextLabel")
label.AnchorPoint = Vector2.new(0.5, 0)
label.Position = UDim2.new(0.5, 0, 0, 16)
label.Size = UDim2.fromOffset(420, 44)
label.BackgroundColor3 = Color3.fromRGB(18, 11, 31)
label.TextColor3 = Color3.new(1, 1, 1)
label.TextScaled = true
label.Visible = false
label.Parent = gui
local messageCount = 0
-- No player parameter here: the server already chose who receives it
notify.OnClientEvent:Connect(function(message: string)
messageCount += 1
local thisMessage = messageCount
label.Text = message
label.Visible = true
task.delay(4, function()
if messageCount == thisMessage then -- hide it unless a newer message replaced it
label.Visible = false
end
end)
end)
The server fires on PlayerAdded, which can happen before this LocalScript has connected its handler. That is fine: Roblox queues RemoteEvent messages that arrive before a handler exists and delivers them, in order, once one connects.
The client does not need to guard against the server: only server code can fire OnClientEvent. The message: string annotation is for the type checker; it is not a runtime check.
Pattern 3: server to all clients
FireAllClients sends the same message to every connected player. It takes no Player argument, and each client receives it on the same OnClientEvent as before, so the NotifyClient script from Pattern 2 already shows it.
This Script announces to everyone when a player first touches a finish line. Add an anchored Part named FinishLine to Workspace, then add the Script to ServerScriptService.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local notify = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("Notify") :: RemoteEvent
local finishLine = workspace:WaitForChild("FinishLine") :: BasePart
local finished: { [Player]: boolean } = {}
finishLine.Touched:Connect(function(hit: BasePart)
local character = hit:FindFirstAncestorOfClass("Model")
local player = character and Players:GetPlayerFromCharacter(character)
if not player or finished[player] then
return -- not a player, or already announced
end
finished[player] = true
-- Every connected client runs its OnClientEvent handler with this text
notify:FireAllClients(`{player.DisplayName} reached the finish!`)
end)
Players.PlayerRemoving:Connect(function(player: Player)
finished[player] = nil
end)
An exploiter can set off Touched for their own character from any distance, which is harmless for an announcement shown once per player. If you later give a reward for finishing, add server checks before paying, such as a minimum time since the round started.
FireAllClients reaches only the players connected at that moment. Someone who joins a second later never sees the message. That is right for one-off moments, such as an announcement, an explosion effect or a sound, but wrong for state such as a round timer or the current game phase. For state, set an attribute on the server, for example ReplicatedStorage:SetAttribute("TimeLeft", 30), and read it on the client with GetAttribute and GetAttributeChangedSignal("TimeLeft"). Attributes replicate to every client, including players who join later.
RemoteFunction: when the client needs an answer
A RemoteEvent is fire and forget. A RemoteFunction makes the caller wait: InvokeServer pauses the code that called it until the server's OnServerInvoke function returns, then hands back whatever it returned. Use one when the client cannot carry on without the answer, such as "was that code valid?". When you do not need a reply, Roblox recommends a RemoteEvent, because it does not wait.
Example: a box where players type a reward code. The server holds the codes, checks the text, and returns two values: whether it worked, and a message to show.
- In
ReplicatedStorage.Remotes, insert a RemoteFunction namedRedeemCode. - In StarterGui, insert a ScreenGui named
CodeGui, and inside it a TextBox namedCodeBox. Size and place it where you like. - Add the Script below to ServerScriptService, and the LocalScript inside
CodeBox.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local redeemCode = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("RedeemCode") :: RemoteFunction
-- Codes live on the server, where no player can read them
local CODES: { [string]: number } = {
LAUNCH = 100,
THANKYOU = 50,
}
local MAX_LENGTH = 20
local COOLDOWN_SECONDS = 2
local usedCodes: { [Player]: { [string]: boolean } } = {}
local lastTry: { [Player]: number } = {}
-- Assign with =. OnServerInvoke is a callback, not an event you Connect to.
redeemCode.OnServerInvoke = function(player: Player, code: unknown): (boolean, string)
local now = os.clock()
local last = lastTry[player]
if last and now - last < COOLDOWN_SECONDS then
return false, "Wait a moment before trying again"
end
lastTry[player] = now
if type(code) ~= "string" or #code == 0 or #code > MAX_LENGTH then
return false, "Type a code first"
end
local key = string.upper(code)
local reward = CODES[key]
if not reward then
return false, "That code doesn't exist"
end
local used = usedCodes[player]
if not used then
used = {}
usedCodes[player] = used
end
if used[key] then
return false, "You already used that code"
end
local leaderstats = player:FindFirstChild("leaderstats")
local coins = leaderstats and leaderstats:FindFirstChild("Coins")
if not (coins and coins:IsA("IntValue")) then
return false, "Coins aren't set up in this game yet"
end
used[key] = true
coins.Value += reward
return true, `Code accepted: +{reward} coins`
end
Players.PlayerRemoving:Connect(function(player: Player)
usedCodes[player] = nil
lastTry[player] = nil
end)
It adds to the Coins stat from the leaderboard guide. Used codes are remembered only until the player leaves, so as written a player can rejoin and redeem the same code again. If coins are saved, that is free coins on every rejoin: before a live game uses it, store each player's used codes with their saved data in a data store.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local redeemCode = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("RedeemCode") :: RemoteFunction
local box = script.Parent :: TextBox
local GREEN = Color3.fromRGB(46, 204, 113)
local RED = Color3.fromRGB(231, 76, 60)
box.FocusLost:Connect(function(enterPressed: boolean)
local code = box.Text
if not enterPressed or code == "" then
return
end
box.Text = ""
box.PlaceholderText = "Checking..."
-- InvokeServer pauses this function until the server's callback returns.
-- pcall keeps the LocalScript running if the call fails.
local ok, accepted, message = pcall(function()
return redeemCode:InvokeServer(code)
end)
if ok and type(message) == "string" then
box.PlaceholderText = message
box.PlaceholderColor3 = if accepted == true then GREEN else RED
else
box.PlaceholderText = "Something went wrong, try again"
box.PlaceholderColor3 = RED
end
end)
A RemoteFunction has exactly one server callback. If two scripts set OnServerInvoke on the same RemoteFunction, only the last one runs.
Why InvokeClient is risky
A RemoteFunction also works the other way, with InvokeClient on the server and OnClientInvoke on the client. Roblox's documentation lists three serious risks:
- If the client's callback throws an error, the server throws the error too.
- If the player leaves while being invoked,
InvokeClientthrows an error. - If the client never returns a value, the server script waits forever.
A modified client controls its own callback, so a cheater can cause the third one on purpose and hold that server thread for as long as they like. To update a player's screen, use FireClient. If the server really needs information from a client, fire a RemoteEvent, have the client reply on another RemoteEvent, and treat a missing reply as a normal case on the server.
Validate every argument on the server
An exploiter can fire any RemoteEvent or invoke any RemoteFunction, as often as they like, with any arguments. The one thing they cannot fake is the first parameter: Roblox fills in the Player who sent it. Everything after that must be checked before the server acts on it. Hiding the remote or checking in the LocalScript does not help, because the cheater controls their own client.
| Check | The question the server asks | Example |
|---|---|---|
| Type | Is it the type I expect? An Instance, not a table built to look like one? | typeof(part) == "Instance" and part:IsA("BasePart") |
| Range | Is the number finite and inside the allowed range? Is the text a sane length? | math.isfinite(n), n >= 1 and n <= 4, #text <= 20 |
| Ownership | Does this player own the item, have the money, or have the right to act on this object? | The item is in their inventory; the part is inside Paintable |
| Distance | Is the player's character close enough on the server's copy of the world? Allow some slack for lag. | Within 30 studs of the part |
| Rate | Is this player sending too fast? | A per-player cooldown or token bucket |
NaN deserves its own warning. It is a number, so type(x) == "number" passes, but every comparison with it is false, so a check like x < 0 or x > 100 lets it straight through. math.isfinite(x) rejects NaN and both infinities.
Know the limit of a distance check. Each client controls the physics of its own character, so an exploiter can move their character next to the target before firing. The check still stops the easy abuse of acting on the whole map from one spot. Keep target parts anchored too: Roblox's security docs note that an exploiter can take network ownership of unanchored parts near them and pull those parts close, which gets round a distance check.
Here are the same checks as a ModuleScript you can reuse in every remote handler.
local Players = game:GetService("Players")
local RemoteGuard = {}
-- A real number between min and max. Rejects NaN and infinity, which pass a
-- plain type(x) == "number" check.
function RemoteGuard.number(value: unknown, min: number, max: number): number?
if type(value) ~= "number" or not math.isfinite(value) then
return nil
end
if value < min or value > max then
return nil
end
return value
end
-- A whole number between min and max, for counts, indexes and quantities
function RemoteGuard.integer(value: unknown, min: number, max: number): number?
local n = RemoteGuard.number(value, min, max)
if n and n % 1 == 0 then
return n
end
return nil
end
-- Text with a length cap. utf8.len returns nil for invalid UTF-8, which data
-- stores reject.
function RemoteGuard.text(value: unknown, maxLength: number): string?
if type(value) ~= "string" or #value > maxLength or utf8.len(value) == nil then
return nil
end
return value
end
-- An Instance of the expected class, inside the container you allow.
-- A table built to look like an Instance fails the typeof check.
function RemoteGuard.instanceIn(value: unknown, className: string, container: Instance): Instance?
if typeof(value) ~= "Instance" then
return nil
end
if not value:IsA(className) or not value:IsDescendantOf(container) then
return nil
end
return value
end
-- True when the player's character is within maxDistance studs of position
function RemoteGuard.isNear(player: Player, position: Vector3, maxDistance: number): boolean
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
if not (root and root:IsA("BasePart")) then
return false
end
return (root.Position - position).Magnitude <= maxDistance
end
-- A per-player rate limit. Each player may make `burst` calls at once, and
-- earns back `perSecond` calls every second. Returns a function to call
-- before doing the work: it answers true (go ahead) or false (too fast).
function RemoteGuard.limiter(burst: number, perSecond: number): (Player) -> boolean
local buckets: { [Player]: { tokens: number, updated: number } } = {}
Players.PlayerRemoving:Connect(function(player: Player)
buckets[player] = nil
end)
return function(player: Player): boolean
local now = os.clock()
local bucket = buckets[player]
if not bucket then
bucket = { tokens = burst, updated = now }
buckets[player] = bucket
end
bucket.tokens = math.min(burst, bucket.tokens + (now - bucket.updated) * perSecond)
bucket.updated = now
if bucket.tokens < 1 then
return false
end
bucket.tokens -= 1
return true
end
end
return RemoteGuard
Keep it in ServerScriptService. Code there never reaches clients, so nobody can read your checks.
And here is the Pattern 1 handler rewritten with it. Replace the contents of PaintServer:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
-- ":: any" only matters to Luau's strict type checker; it changes nothing at runtime
local RemoteGuard = require(ServerScriptService:WaitForChild("RemoteGuard")) :: any
local paintRequest = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("PaintRequest") :: RemoteEvent
local paintable = workspace:WaitForChild("Paintable")
local PALETTE = {
Color3.fromRGB(231, 76, 60),
Color3.fromRGB(46, 204, 113),
Color3.fromRGB(52, 152, 219),
Color3.fromRGB(241, 196, 15),
}
local canPaint = RemoteGuard.limiter(4, 4) -- a burst of 4, then 4 a second
paintRequest.OnServerEvent:Connect(function(player: Player, partArg: unknown, colourArg: unknown)
local part = RemoteGuard.instanceIn(partArg, "BasePart", paintable) :: BasePart?
local colourIndex = RemoteGuard.integer(colourArg, 1, #PALETTE) :: number?
if not part or not colourIndex then
return
end
if not RemoteGuard.isNear(player, part.Position, 30) or not canPaint(player) then
return
end
part.Color = PALETTE[colourIndex]
end)
The limiter removes each player's entry when they leave, so its table does not grow for the whole life of the server.
What you can and can't send
You can pass numbers, strings, booleans, Roblox types such as Vector3, CFrame, Color3 and Enum items, Instances that both sides can see, and tables of those. Some values change or disappear on the way:
| You send | What happens |
|---|---|
| A function | The other side receives nil |
| A table with a metatable | The metatable is lost, along with anything the table only had through it |
| A table mixing number keys and string keys | Roblox says not to: send a pure array or a pure dictionary |
A table with nil values in it | Avoid: Roblox advises against nil at any index |
| A table with Instance, userdata or function keys | Those keys are converted to strings |
| An Instance the receiver cannot see, such as one in ServerStorage or one a LocalScript created | The other side receives nil |
| Any table | The receiver gets a copy, never the same table |
A good habit is to send identifiers rather than objects or values: the name of an item, the index of a choice, a part the server can look up and check. The server then works from its own data.
Why a RemoteFunction returns nil
If InvokeServer gives you nil, work through these in order:
- A path with no return value. Every way out of
OnServerInvokemust return something. A barereturnin an early check sends backnil. - A return inside a nested function. A
returninsidepcall(function() ... end), a:Connecthandler ortask.spawnonly leaves that inner function. Capture the result, for examplelocal ok, data = pcall(...), then returndatafrom the callback itself. - A value that cannot cross. A function, or an Instance the client cannot see (anything in ServerStorage, for example), arrives as
nil. With instance streaming on, a part the server has just created may not have reached the client yet when the function returns. - Another script set the callback. Only the last assignment to
OnServerInvokeruns. Search your scripts for the remote's name.
If the call never returns at all, no script has set OnServerInvoke yet (Roblox queues the calls until one does), or the callback is stuck, for example on a WaitForChild for something that never appears.
UnreliableRemoteEvent: for constant, throwaway updates
An UnreliableRemoteEvent has the same methods and events as a RemoteEvent: FireServer, FireClient, FireAllClients, OnServerEvent and OnClientEvent. The difference is that Roblox guarantees neither delivery nor order. A message can be dropped, without being resent, when:
- it is lost in transit, or waits too long on a congested network
- its payload is larger than 1,000 bytes
- a client fires faster than the rate limit (the excess is dropped, not delayed)
- no handler is connected when it arrives (it is discarded, not queued)
Messages can also arrive out of order, and they have no ordering relationship with your RemoteEvents or RemoteFunctions. Use one when only the newest value matters and the next update is a fraction of a second away: where a player is aiming, the position of a cosmetic effect, a value that changes every frame. If order matters, put a sequence number or timestamp in each message and ignore anything older than the last one you handled.
Never use one for something that must happen exactly once, such as a purchase, damage, a reward or a round starting. And validate its arguments on the server exactly as you would for a RemoteEvent.
Delivery order and rate limits
Roblox's delivery guarantees apply to one connection in one direction: the server to one client, or one client to the server.
- RemoteEvents are reliable and ordered. Lost messages are resent while the recipient stays connected, and messages arrive in the order you fired them, even across different RemoteEvents. If the server fires A, then B, then A to a player, that player receives A, B, A.
- Order is about when handlers start. If a handler yields, for example with
task.waitorWaitForChild, the next message can start being handled before the first one finishes. - RemoteFunctions and RemoteEvents are not ordered against each other. If your code depends on strict order, send related messages through the same remote type, or add your own sequence number.
- Property and attribute changes are not ordered against remotes. If the server changes a property and then fires a remote, the client may see either first. Listen for the change itself, for example with
GetPropertyChangedSignal, rather than assuming it has arrived. - No handler yet. RemoteEvent messages queue until a handler connects. The queue has limits: once it is full, further messages are discarded and Output shows a
Remote event invocation discardederror. - A player who leaves loses any messages that had not reached them yet.
Rate limit. RemoteEvent and UnreliableRemoteEvent messages from a client to the server are throttled at about 500 requests per second per client, as documented on 30 September 2026. The limit is shared by every remote of the same type, so splitting one busy remote into ten does not raise it. Past the limit, RemoteEvent messages are processed later and keep their order, while UnreliableRemoteEvent messages are dropped. The engine limit does nothing to stop one player firing your remote 50 times a second, so every handler that changes something still needs its own per-player limit.
RemoteEvent, RemoteFunction, UnreliableRemoteEvent or attribute?
| Use | For | Example | Watch out for |
|---|---|---|---|
| RemoteEvent | One-way messages that must arrive, in order | Client: "paint this part". Server: "show this notice" | Validate every argument on the server |
RemoteFunction with InvokeServer | The client needs an answer before it can carry on | Redeem a code, confirm a purchase | The caller waits; every path must return a value |
RemoteFunction with InvokeClient | Almost never | Use FireClient instead | A client that errors, leaves or never answers breaks or freezes the server call |
| UnreliableRemoteEvent | Frequent updates where only the latest matters | Aim direction, cosmetic effects | Messages dropped or reordered; 1,000-byte payload limit |
| Attribute or value object set by the server | Shared state that should always be visible, including to players who join later | Round timer, game phase, a door's open state | Only server changes reach everyone; clients listen with GetAttributeChangedSignal or Changed |
| BindableEvent | Scripts on the same side talking to each other | One server script telling another that a round ended | Does not cross between server and client |
For a full example of RemoteFunctions and validation working together on something players pay for, see how to make a shop GUI.
Common errors and fixes
Most remote problems show up in the Output window (open it from the Window menu or the Script tab). Check it first, then match the symptom below. The script errors guide covers the general error messages in more depth.
Infinite yield possible on a WaitForChild for a remote
A script is waiting for a remote that never arrives. Check that the name and capitalisation match exactly, and that the remote is in ReplicatedStorage, not ServerStorage or ServerScriptService, which clients cannot see.
The server receives the wrong values, shifted by one
The LocalScript passed the player itself, as in remote:FireServer(player, 5). Roblox already adds the sender as the first parameter, so the server sees (player, player, 5). Call remote:FireServer(5).
Nothing happens when I fire the remote
Check each call runs on the right side: FireServer and InvokeServer only work from client code, and FireClient, FireAllClients and InvokeClient only from a server Script. A LocalScript never runs in ServerScriptService, ServerStorage or directly in Workspace; put it in StarterPlayerScripts, StarterGui, StarterCharacterScripts or StarterPack. Then look in Output for an error inside the handler.
Remote event invocation discarded
Messages arrived for a RemoteEvent that had no connected handler, and the queue filled up. Usually the handler script errors before its :Connect line, is disabled, or is stuck waiting for something that never loads. Connect handlers near the top of the script.
It works for me, but other players don't see the change
The change was made on the client, in a LocalScript or an OnClientEvent handler. Make it in the server's OnServerEvent handler instead.
InvokeServer never returns
Either no script has set OnServerInvoke (Roblox queues calls until one does), or the callback is stuck on something that yields, such as a WaitForChild or a data store request. Add a print at the start and end of the callback to see where it stops.
Build it with RoCode
You can also have RoCode set this up in the place you have open. RoCode is an AI agent that works through a Roblox Studio plugin, so it creates the remotes and scripts in your Explorer rather than giving you code to paste.
Let players click parts in the Paintable folder to paint them. Use a RemoteEvent, and keep the checks on the server: only parts in that folder, within 30 studs, and no more than a few paints a second.
- Usually looks through the place first, with Search Game and Search Scripts, for an existing Remotes folder or handler, and uses your existing folders when they are there
- Creates the RemoteEvent in ReplicatedStorage with Create Instance, and writes the server Script and the LocalScript with Create Script
- Compile-checks each script with Check Script inside Studio before it finishes. That catches compile errors, not logic bugs: it doesn't type-check, lint or run your code
- Sends its changes in batches, and each batch is an undo point in Studio's history
RoCode does not start playtests. Press Play yourself and try to break the checks, for example by painting from across the map. If something errors, Check Output can read the Server and Client lines from your last playtest. How RoCode connects to Studio.
Questions
Are RemoteFunctions safe to use?
InvokeServer is as safe as your server callback: validate its arguments and rate-limit it like any RemoteEvent. InvokeClient is the risky direction, because a client that errors, leaves or never returns breaks or freezes the server's call.
Can exploiters see and fire my remotes?
Yes. Everything in ReplicatedStorage reaches every client, and a modified client can fire any remote with any arguments. Renaming or hiding remotes does not make them safe; checking every argument on the server does.
Can one client send a RemoteEvent to another client?
Not directly. Fire it to the server, check it there, then use FireClient or FireAllClients.
How often can I fire a RemoteEvent?
Roblox documents a limit of about 500 requests per second per client for RemoteEvent and UnreliableRemoteEvent messages sent to the server, shared by all remotes of the same type. Stay well under it, and give each handler its own per-player limit.
Does FireAllClients reach players who join later?
No. It reaches the players connected at that moment. For state that late joiners need, set an attribute or a value object on the server.
What is the difference between a RemoteEvent and a BindableEvent?
A RemoteEvent crosses between server and client. A BindableEvent connects scripts on the same side, such as two server scripts.
Sources and further reading
- Roblox Creator Docs: Remote events and callbacks. FireServer, FireClient, InvokeServer, InvokeClient risks, argument limitations and delivery guarantees
- Roblox Creator Docs: Securing the client-server boundary. Validation layers, NaN, rate limiting, client-to-client relays
- Roblox Creator Docs: Security and cheat mitigation tactics. What a modified client can do, and server authority
- Roblox Creator Docs: Network ownership, movement validation, and physics. Why a client controls its own character, and the limits of distance checks
- Roblox Engine API: RemoteEvent. Throttling limit and the invocation discarded error
- Roblox Engine API: RemoteFunction. OnServerInvoke, InvokeClient warnings and streaming precautions
- Roblox Engine API: UnreliableRemoteEvent. The 1,000-byte payload limit
- Roblox Creator Docs: Properties and attributes. Replication order between property changes and remote events
How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 30 September 2026). A type check catches misspelt APIs and wrong types, not game logic, so play-test in Studio before you publish.