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.

criticalLuaudit check: shared-key-race

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 a SetAsync on 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.
  • UpdateAsync and IncrementAsync change a key in one step and are the fix. Check that the UpdateAsync transform 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 IncrementAsync for whole-number counters.
  • Use UpdateAsync for everything else. Inside the transform function, check the value you're given and return nil to cancel when the change isn't allowed, such as when the bank can't cover a withdrawal.
  • Give the player their share only after UpdateAsync has 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.
  • SetAsync that 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.