What to do when a player leaves before their data loads
A load that finishes after the player left can hold their session lock for the life of the server. How to check the player is still there before keeping the data.
What it looks like
A player's data loads in PlayerAdded, with retries and waits. If the player leaves before the load finishes, PlayerRemoving runs first, finds nothing to save or release, and moves on. Then the load finishes and stores the data in the server's table for a player who is gone. Nothing ever removes it, and the autosave keeps saving it.
Players.PlayerAdded:Connect(function(player) profiles[player] = loadWithRetries(player) end)
What an exploiter could do
A player who leaves while their data loads, for example because they left quickly or their connection dropped, can't load their data in any other server while this one holds the session lock, and the autosave keeps renewing it. Without a lock it's worse: they play on in another server, and this server's autosave later writes the old copy over their newer progress, which an exploiter can use to undo spending or trades they've already been paid for. The server also keeps the data and the Player object in memory until it shuts down.
How to fix it
- After the load finishes, check
player.Parent == Players. If the player has gone, release the session lock straight away and don't store the data. - Have the autosave skip any player who is no longer in the game.
- Keep failed and pending loads apart from loaded data, so
PlayerRemovingknows a load is still running.
Vulnerable example
local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") local store = DataStoreService:GetDataStore("Gems") local gems: { [Player]: number } = {} Players.PlayerAdded:Connect(function(player) for attempt = 1, 3 do local ok, saved = pcall(store.GetAsync, store, tostring(player.UserId)) if ok then gems[player] = if typeof(saved) == "number" then saved else 0 return end task.wait(attempt * 2) end player:Kick("Your gems didn't load. Please rejoin.") end) Players.PlayerRemoving:Connect(function(player) local count = gems[player] gems[player] = nil if count then pcall(store.SetAsync, store, tostring(player.UserId), count) end end) task.spawn(function() while true do task.wait(120) for player, count in gems do pcall(store.SetAsync, store, tostring(player.UserId), count) end end end)
Fixed example
local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") local store = DataStoreService:GetDataStore("Gems") local gems: { [Player]: number } = {} Players.PlayerAdded:Connect(function(player) for attempt = 1, 3 do local ok, saved = pcall(store.GetAsync, store, tostring(player.UserId)) if player.Parent ~= Players then return end if ok then gems[player] = if typeof(saved) == "number" then saved else 0 return end task.wait(attempt * 2) end player:Kick("Your gems didn't load. Please rejoin.") end) Players.PlayerRemoving:Connect(function(player) local count = gems[player] gems[player] = nil if count then pcall(store.SetAsync, store, tostring(player.UserId), count) end end) task.spawn(function() while true do task.wait(120) for player, count in gems do if player.Parent == Players then pcall(store.SetAsync, store, tostring(player.UserId), count) end end end end)
A real profile also needs a session lock, as Player data with no session lock describes. The same check applies after the lock is claimed: release it if the player has gone.
How to spot this in your own scripts, and the cases that only look like it, are part of the Luaudit check your AI runs.