How to save player data when a Roblox server shuts down

Saving only in PlayerRemoving can lose data when a server closes. How to use BindToClose to wait for every save to finish.

warningLuaudit check: unsafe-shutdown-save

What it looks like

Data is saved only in Players.PlayerRemoving, with no game:BindToClose handler, or with one that doesn't wait for the saves to finish. When the server shuts down, it can close before the saves land.

Luau
Players.PlayerRemoving:Connect(save)

How to detect it

  • Find where each data store is written. If the only save is in PlayerRemoving, look for game:BindToClose.
  • BindToClose gives the server up to 30 seconds before it closes. Check that the handler saves every player still in the server, or waits for saves already running, and that it doesn't return before they finish.
  • Look for an autosave loop. Without one, a server that crashes loses everything since each player joined.
  • Check that a failed save is retried or at least logged, rather than ignored.

What an exploiter could do

No exploit is needed. When an update shuts servers down, or a server closes as the last player leaves, players can lose whatever they earned or bought in that session. Players who lose purchases often report it as theft, and it can't be fixed from the game afterwards.

How to fix it

  • Add game:BindToClose and save every remaining player from it, in parallel with task.spawn, then wait until they've all finished before returning.
  • Keep a set of saves in progress so BindToClose can wait for ones that PlayerRemoving already started.
  • Autosave every few minutes, and retry failed saves a couple of times.

Vulnerable example

Vulnerable
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local store = DataStoreService:GetDataStore("Wins")
local wins: { [Player]: number } = {}

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
	wins[player] = if typeof(saved) == "number" then saved else 0
end)

Players.PlayerRemoving:Connect(function(player)
	local count = wins[player]
	wins[player] = nil
	if count then
		pcall(function()
			store:SetAsync(tostring(player.UserId), count)
		end)
	end
end)

Fixed example

Fixed
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local store = DataStoreService:GetDataStore("Wins")
local wins: { [Player]: number } = {}
local saving = 0

local function save(player: Player)
	local count = wins[player]
	if not count then
		return
	end
	saving += 1
	for attempt = 1, 3 do
		local ok = pcall(function()
			store:SetAsync(tostring(player.UserId), count)
		end)
		if ok then
			break
		end
		task.wait(attempt)
	end
	saving -= 1
end

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
	wins[player] = if typeof(saved) == "number" then saved else 0
end)

Players.PlayerRemoving:Connect(function(player)
	save(player)
	wins[player] = nil
end)

game:BindToClose(function()
	for _, player in Players:GetPlayers() do
		task.spawn(save, player)
	end
	while saving > 0 do
		task.wait()
	end
end)

task.spawn(function()
	while true do
		task.wait(300)
		for _, player in Players:GetPlayers() do
			task.spawn(save, player)
		end
	end
end)

Common false positives

  • A well-known profile library usually handles shutdown and autosave itself. Check the game releases profiles through it rather than saving by hand.
  • Data that doesn't matter if lost, like a "last seen" time, doesn't need shutdown handling.
  • A BindToClose handler that saves players one after another still works for small servers. Mention that saving in parallel is safer for big ones rather than reporting it.