How to session-lock player data in Roblox
Without a session lock, two servers can hold the same player's data and the later save wins. How session locking with UpdateAsync works.
What it looks like
The server loads a player's profile with GetAsync and saves it with SetAsync, and nothing stops a second server loading the same profile before the first has saved it.
Luau
profiles[player] = store:GetAsync(key)
How to detect it
- Find how the player's main profile is loaded and saved. Look for a lock: the load marks the record as in use by this server, such as
game.JobIdand a time written insideUpdateAsync, and refuses or waits when another server's lock is recent. The save on leave releases it. GetAsyncfollowed bySetAsyncwith no lock is a finding. So is a lock that's read withGetAsyncand written separately, since two servers can both read "unlocked" before either writes.- A well-known session-locking profile library counts as a lock. Check the game uses its load and release functions rather than reading the store directly.
- The impact is highest when saved items or currency can move between players, through trading, gifting, tipping or a shared bank. Note which features do.
What an exploiter could do
When a player leaves, their save takes a moment to land. If they join another server in that gap, it loads their old data. Anything they gave away in the first server, they still have in the second, so items and currency can be duplicated. Even without trading, the two servers' saves can overwrite each other and lose progress.
How to fix it
- Load with
UpdateAsync. Inside it, if another server's lock is recent, returnnilto leave the record untouched and try again shortly; otherwise write this server'sgame.JobIdand the time, and keep the data. - Release the lock in the same
UpdateAsyncthat saves on leave, and only if this server still holds it. Release it too if the player leaves while their data is still loading. - Refresh the lock's time on each autosave, and treat a lock older than the expiry as abandoned, so a crashed server doesn't lock a player out for good.
- If the lock can't be claimed after a few tries, kick the player with a message rather than loading data another server may still change.
- Using an established session-locking module does all of this and is usually the better choice.
Vulnerable example
Vulnerable
local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") type Saved = { coins: number, inventory: { string }, } local store = DataStoreService:GetDataStore("Profiles") local profiles: { [Player]: Saved } = {} Players.PlayerAdded:Connect(function(player) local ok, saved = pcall(function() return store:GetAsync(tostring(player.UserId)) end) if not ok then player:Kick("We couldn't load your data. Please rejoin in a moment.") return end profiles[player] = saved or { coins = 0, inventory = {} } end) Players.PlayerRemoving:Connect(function(player) local data = profiles[player] profiles[player] = nil if data then pcall(function() store:SetAsync(tostring(player.UserId), data) end) end end)
Fixed example
Fixed
local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") type Saved = { coins: number, inventory: { string }, } type Lock = { jobId: string, time: number, } type Record = { data: Saved, lock: Lock?, } local store = DataStoreService:GetDataStore("Profiles") local lockExpiry = 30 * 60 local autosaveEvery = 5 * 60 local profiles: { [Player]: Saved } = {} local function lockedElsewhere(record: Record?): boolean local lock = record and record.lock return lock ~= nil and lock.jobId ~= game.JobId and os.time() - lock.time < lockExpiry end local function claim(key: string): Saved? local claimed: Saved? = nil local ok = pcall(function() store:UpdateAsync(key, function(old: Record?): Record? claimed = nil if lockedElsewhere(old) then return nil end local record: Record = old or { data = { coins = 0, inventory = {} } } record.lock = { jobId = game.JobId, time = os.time() } claimed = record.data return record end) end) return if ok then claimed else nil end local function save(player: Player, data: Saved) pcall(function() store:UpdateAsync(tostring(player.UserId), function(old: Record?): Record? if lockedElsewhere(old) then return nil end local lock = if profiles[player] == data then { jobId = game.JobId, time = os.time() } else nil return { data = data, lock = lock } end) end) end Players.PlayerAdded:Connect(function(player) local key = tostring(player.UserId) local data = claim(key) for attempt = 1, 4 do if data or player.Parent ~= Players then break end task.wait(attempt * 3) data = claim(key) end if player.Parent ~= Players then if data then save(player, data) end return end if not data then player:Kick("Your data is still saving from another server. Please rejoin in a moment.") return end profiles[player] = data end) Players.PlayerRemoving:Connect(function(player) local data = profiles[player] profiles[player] = nil if data then save(player, data) end end) task.spawn(function() while true do task.wait(autosaveEvery) for player, data in profiles do task.spawn(save, player, data) end end end)
Common false positives
- Data that one server owns alone and that can't be traded, such as per-player settings, gains little from a lock. Report it only if two servers saving it could lose something that matters.
- A lock taken inside
UpdateAsyncand released on save, with an expiry, is the fix. Don't report it because it's hand-written. - Places in the same experience share data stores, so a lock that works across places is still the right fix there.