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.

warningLuaudit check: loads-after-leaving

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.

Luau
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 PlayerRemoving knows a load is still running.

Vulnerable example

Vulnerable
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

Fixed
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.