How to use UpdateAsync for values many servers change
GetAsync followed by SetAsync lets two servers overwrite each other's changes to a bank, counter or limited stock. How to use UpdateAsync and IncrementAsync instead.
What it looks like
A value that more than one server changes, such as a group bank, a global counter, a limited-stock item or a gift sent to an offline player, is updated with GetAsync followed by SetAsync. Two servers can read the same old value, and the second write wipes out the first.
Luau
local balance = store:GetAsync("Bank") or 0
store:SetAsync("Bank", balance + amount)How to detect it
- Find every data store key that isn't one player's own profile: group or clan keys, global keys, mailboxes, limited stock, keys for another player's data.
- For each one, look at how it's changed. A
GetAsync, some maths, then aSetAsyncon the same key is a finding, even if they sit next to each other. Another server can write between the two calls. - Check what the change guards. If the read is used to decide whether something is allowed, such as "the bank has enough" or "there's stock left", two servers can both say yes.
UpdateAsyncandIncrementAsyncchange a key in one step and are the fix. Check that theUpdateAsynctransform function makes its decision from the value it's given, not from a value read earlier.
What an exploiter could do
Players in two servers act at once: both withdraw from the same bank, both buy the last limited item, or both send gifts to the same mailbox. One write overwrites the other, so currency or items are paid out twice, or deposits and gifts vanish.
How to fix it
- Use
IncrementAsyncfor whole-number counters. - Use
UpdateAsyncfor everything else. Inside the transform function, check the value you're given and returnnilto cancel when the change isn't allowed, such as when the bank can't cover a withdrawal. - Give the player their share only after
UpdateAsynchas succeeded and the transform accepted the change. The transform can run more than once, so set the result there and read it after the call returns. - Keep the transform function quick and free of yields.
Vulnerable example
Vulnerable
local DataStoreService = game:GetService("DataStoreService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local stock = DataStoreService:GetDataStore("LimitedStock") local itemId = "GoldenCrown" local price = 5000 ReplicatedStorage.Remotes.BuyLimited.OnServerEvent:Connect(function(player: Player) local profile = PlayerData.get(player) if not profile or profile.Coins < price then return end local ok, left = pcall(function() return stock:GetAsync(itemId) end) if not ok or typeof(left) ~= "number" or left < 1 then return end local saved = pcall(function() stock:SetAsync(itemId, left - 1) end) if saved and profile.Coins >= price then profile.Coins -= price table.insert(profile.Inventory, itemId) end end)
Fixed example
Fixed
local DataStoreService = game:GetService("DataStoreService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local stock = DataStoreService:GetDataStore("LimitedStock") local itemId = "GoldenCrown" local price = 5000 local buying: { [Player]: boolean } = {} ReplicatedStorage.Remotes.BuyLimited.OnServerEvent:Connect(function(player: Player) local profile = PlayerData.get(player) if buying[player] or not profile or profile.Coins < price then return end buying[player] = true profile.Coins -= price local taken = false local ok = pcall(function() stock:UpdateAsync(itemId, function(left: unknown): number? taken = false if typeof(left) ~= "number" or left < 1 then return nil end taken = true return left - 1 end) end) if ok and taken then table.insert(profile.Inventory, itemId) else profile.Coins += price end buying[player] = nil end)
Common false positives
- A key only one server ever writes, such as a player's own profile under a session lock, isn't shared. Report missing locks under Player data with no session lock instead.
SetAsyncthat overwrites with a value that doesn't depend on what was there, like the time a leaderboard was last reset, can't lose an update.- Reads for display only, like showing the bank balance, are fine with
GetAsync.