How to stop yields letting players claim or spend twice
A yield between a check and the change it guards lets the same remote pass the check twice. How to order handlers so the change happens first.
What it looks like
A handler checks something, such as a balance, an item in the inventory or a "not claimed yet" record, then yields before it makes the change that check was guarding. While it waits, the same player can fire the remote again, and the second call passes the same check because the first hasn't changed anything yet.
if profile.Coins >= price then task.wait(1) profile.Coins -= price table.insert(profile.Inventory, itemId) end
How to detect it
- Each remote call runs in its own thread, so one handler can be running several times at once for the same player. Any yield inside a handler lets another call run in the gap.
- For each handler that grants something, find the check that guards it and the line that makes the matching change: taking the cost, removing the item, writing the claim. List every call between the two that can yield:
task.wait,wait,WaitForChild, event:Wait(),InvokeClient, any method ending inAsync(data stores,MarketplaceService,GroupService,HttpService,TeleportService), and calls into modules that do any of these. - If anything between the check and the change can yield, it's a finding. Read helper modules before deciding a call doesn't yield.
- A cost taken inside a later thread, such as
task.delayortask.spawn, counts as after a yield.
What an exploiter could do
They fire the remote several times in quick succession. Every call passes the check before any of them makes the change, so one balance pays for several purchases, one set of materials is spent several times, or a one-off reward pays out once per call. This is one of the most common causes of duplicated items and currency.
How to fix it
- Make the change straight after the check, with nothing that yields in between. Take the cost or record the claim first, then do the slow part: the wait, the effect, the web request.
- If the slow part can fail and the player should get their cost back, refund it explicitly.
- When a yielding call has to decide something, such as a game pass check that changes the price, make that call before the check. Then check and change together.
- If the yield really has to sit between them, set a per-player busy flag before it, ignore calls while it's set, and clear it on every path, including when the yielding call errors and in
Players.PlayerRemoving.
Vulnerable example
local MarketplaceService = game:GetService("MarketplaceService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local Catalogue = require(ReplicatedStorage.Shared.Catalogue) local PlayerData = require(script.Parent.PlayerData) local vipPassId = 123456789 ReplicatedStorage.Remotes.BuyItem.OnServerEvent:Connect(function(player: Player, itemId: unknown) if typeof(itemId) ~= "string" then return end local item = Catalogue[itemId] local profile = PlayerData.get(player) if not item or not profile or profile.Coins < item.price then return end local ok, isVip = pcall(MarketplaceService.UserOwnsGamePassAsync, MarketplaceService, player.UserId, vipPassId) local price = if ok and isVip then math.floor(item.price * 0.8) else item.price profile.Coins -= price table.insert(profile.Inventory, itemId) end)
Fixed example
local MarketplaceService = game:GetService("MarketplaceService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local Catalogue = require(ReplicatedStorage.Shared.Catalogue) local PlayerData = require(script.Parent.PlayerData) local vipPassId = 123456789 ReplicatedStorage.Remotes.BuyItem.OnServerEvent:Connect(function(player: Player, itemId: unknown) if typeof(itemId) ~= "string" then return end local item = Catalogue[itemId] if not item then return end local ok, isVip = pcall(MarketplaceService.UserOwnsGamePassAsync, MarketplaceService, player.UserId, vipPassId) local price = if ok and isVip then math.floor(item.price * 0.8) else item.price local profile = PlayerData.get(player) if not profile or profile.Coins < price then return end profile.Coins -= price table.insert(profile.Inventory, itemId) end)
Common false positives
- A yield after the change, such as playing an effect or saving, can't be raced. Only yields between the check and the change matter.
- A yield before the check is fine, as long as the check and the change then happen with nothing yielding between them.
- A per-player busy flag set before the yield and cleared on every path stops the second call. Check that an error inside the yield can't leave it set forever. If it can, that player is locked out of the feature, which is a warning rather than this finding.
- Firing a remote with
FireClientorFireAllClientsdoesn't yield.