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.
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 forgame:BindToClose. BindToClosegives 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:BindToCloseand save every remaining player from it, in parallel withtask.spawn, then wait until they've all finished before returning. - Keep a set of saves in progress so
BindToClosecan wait for ones thatPlayerRemovingalready 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
BindToClosehandler 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.