Stop losing saves on shutdown with BindToClose and a parallel flush
Your player leaves the game at shutdown and comes back with the progress of ten minutes ago. The fix is three pieces together: an authoritative in-memory cache, a session lock through UpdateAsync, and a parallel flush with a barrier inside BindToClose.
Produced with AI and checked by automated verification. This text carries no source list. No person read this text before publication. Editorial Policy

Your DataStore is not losing progress on shutdown. It starts writing and the server closes before the request comes back — the value that only existed in memory turns to dust. When you press "Shut Down All Servers" (or when Roblox takes the server down to ship a new build), the thing that has to hold the close open is your code, and almost nobody holds it properly.
Why progress disappears exactly at shutdown
Losing a save on shutdown is always one of these five things, and usually three of them at once.
1. You save in PlayerRemoving and trust that to be enough. On shutdown PlayerRemoving does fire, but the process does not wait for your asynchronous call to finish. What holds the close open is game:BindToClose, and it has at most a 30-second window. Past that, the server dies with your saves still in the queue.
2. The server crashed, it did not close. A crash does not call BindToClose. No callback, no 30 seconds, nothing. There is exactly one defence against a crash: periodic autosave. If your autosave window is 90 seconds, your maximum loss per crash is 90 seconds of play — and that becomes a product decision rather than an accident.
3. You blew the request budget. DataStoreService works on a per-minute budget that scales with the number of people on the server: the documented order of magnitude for writes is 60 plus 10 per player, per minute. A request over budget does not fail immediately, it enters a queue that drains at the budget's pace — and a full queue returns an error. On a 40-player server that fires 40 saves at once, you end up waiting on the queue inside a 30-second window. You can look at the number before designing around it:
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local budget = DataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync)
print(budget, #Players:GetPlayers()) -- if budget < players, part of the flush goes to the queue4. Rollback through migration. On a shutdown from the dashboard, the player is reconnected to a new server. If that new server runs GetAsync before the old server finishes writing, it loads the old data — and when the player leaves there, it writes the old data over the new. The player watches their progress travel back in time and nobody finds an error in the log, because there was no error: there were two writes out of order. Without a session stamp you have no way to know which of the two is the most recent.
5. The write failed and nobody saw. SetAsync without pcall inside a for: the first network error ends the loop and everyone after that index goes unsaved. With no failure log, you find out from players' Discord screenshots three days later.
The step by step: cache, lock, autosave and parallel flush
Step 1 — memory is the source of truth. A DataStore is persistence, not a read database. One GetAsync/UpdateAsync on the way in, one write on the way out, and every mutation of currency, level and inventory happens in an in-memory table you mark as dirty. If your code reads the DataStore to find out how many coins a player has, you are already spending budget that will be missing at shutdown.
Step 2 — swap SetAsync for UpdateAsync everywhere. SetAsync overwrites blind. UpdateAsync hands you the current value and lets you decide whether to write — and returning nil from the transform function cancels the write without consuming an invalid write attempt. That is exactly what lets you refuse to overwrite another server's data.
Step 3 — session lock in the same value. Store two things in the key: the data and a session stamp with game.JobId and a heartbeat in os.time(). On the way in you only take the session if there is no lock or if the heartbeat is old. 120 seconds works well as a dead-lock limit: it is longer than any reasonable autosave window and shorter than a player's patience. If there is a live lock from another server, wait and try again — in the artifact that is 5 attempts of 4 seconds — and only then refuse entry with a Kick explaining why. A kick is less bad than loading a duplicate save: inventory duplication is irreversible and breaks the economy.
Step 4 — staggered autosave, one per tick. Do not save everyone together every 60 seconds. That creates a spike, drops the budget and occasionally makes the spike coincide with the shutdown. Run a 1-second loop and write at most one session per turn: the one that is dirty and has gone longest since its last save. With 40 players and a 90-second window, your peak is 1 write per second, comfortably inside budget.
Step 5 — reserve budget for the close. The autosave should only write if the UpdateAsync budget is above a reserve (8 in the artifact). Shutdown is the most expensive moment of the server's whole life; it needs to find cash on hand when it arrives.
Step 6 — parallel flush with a barrier. Inside BindToClose, fire one task.spawn per session and wait for a counter to reach the total, instead of walking a sequential for. With budget available, 40 parallel writes finish in a few seconds; in series, each one waits out the previous one's latency and you do not fit in the 30 seconds. And stop waiting at 25, not at 30: you want to log the result before you are cut off, otherwise you do not know whether the flush finished.
Step 7 — retry with backoff, and release the lock. Two or three attempts per write, with 1s, 2s and 4s between them. In the exit save and in the flush, write the lock as nil to release the session. If you do not release it and the server crashes, the player is stuck waiting for the heartbeat to expire — and your "we could not open your save" screen becomes a support ticket.
The SessionStore module and how to wire it up
The artifact is two pieces. The first is a ModuleScript called SessionStore, directly in ServerScriptService: it holds the cache, the lock, the staggered autosave, the retry and the flush with a barrier. The second is the Script that wires everything: loads on the way in, releases on the way out, starts the autosave and registers BindToClose.
The API is deliberately short. SessionStore.LoadAsync(player) returns the data table or nil if it refused the session. SessionStore.Get(userId) gives you the same table to read and mutate. SessionStore.MarkDirty(userId) is what you call after changing anything — without it the autosave ignores the session. SessionStore.ReleaseAsync(player) saves and drops the lock. SessionStore.FlushAll() is what goes inside BindToClose.
Two operational details. The store name carries a version suffix (PlayerData_v1) and the data has a version field: when the format changes, you read the version and migrate on load, instead of discovering that half your base has the old schema. And the code already handles the case where the key was written in the old raw format (data straight in the key, with no lock envelope), which is the state most games migrating to locks are in right now.
To test it: Stop in Studio is not a valid test, because with one player you never reproduce a budget overrun (and Studio needs "Enable Studio Access to API Services" switched on to write). Publish, open the game on two clients, spend currency on both, and take it down from the Creator Hub with "Shut Down All Servers". The print [SessionStore] flush: 2/2 in 0.41s appears in the Developer Console's Server log (F9) before the server dies. If it says 1/2, you have a write being cut off, and the time figure tells you whether it was the queue or a retry.
The most common and most expensive mistake: saving in series inside BindToClose
This is the code almost every game has, and it is the direct cause of most shutdown rollbacks:
game:BindToClose(function()
for _, player in Players:GetPlayers() do
store:SetAsync(player.UserId, cache[player.UserId]) -- one at a time, no pcall
end
end)Three defects in five lines. It is serial: with 40 players, each write waits for the previous one, and average latency only has to go above 0.7 s for you to miss the 30 seconds — the last people on the list simply do not save. There is no pcall: the first error raises and the for dies right there, so everyone later in the loop loses everything, including those who would have saved without trouble. And it is SetAsync: if the player already reconnected to the new server, this line writes the old data over the new one — you did not just lose, you reverted.
The fix is the one in the artifact: task.spawn per session, a counter as the barrier, pcall on every write, UpdateAsync refusing to write when the lock already belongs to another server, and an internal 25-second deadline. The second expensive mistake lives exactly here: a long retry inside the flush. A backoff of 1s, 2s, 4s with 5 attempts is 15 seconds of waiting per player — good in the autosave, suicide at shutdown. In the flush, use 2 attempts at most.
What to measure afterwards
Three numbers, and they fit in a print and a counter.
First: done/pending and the flush duration, on every shutdown. If the duration goes past 10 seconds on a full server, your budget is tight before the close — probably the autosave is writing too much, or some system of yours still calls the DataStore during gameplay.
Second: how many loads hit a live lock and how many ended in a Kick. A spike of blocked locks after a shutdown is normal for a few seconds; constantly blocked locks mean you are not releasing the lock somewhere a session ends.
Third: write failures per session. Count them in a MemoryStore hash or push them to your external log, with the userId and the error message. It is the only way to know the problem has moved from "does not save on shutdown" to "does not save because the payload went over 4 MB per key", which is the next ceiling you hit as the inventory grows.
And if you do not want to maintain this, the honest alternative is ProfileStore (by loleris), open source, which already implements session locking and flushing. The value of writing your own is knowing what to do when the behaviour surprises you — and now you know where to look.
Ready-to-use artifact
--!strict
-- SessionStore
-- Authoritative in-memory cache + session lock via UpdateAsync + parallel flush on shutdown.
-- Where: a ModuleScript in ServerScriptService, named exactly "SessionStore".
local DataStoreService = game:GetService("DataStoreService")
local STORE_NAME = "PlayerData_v1"
local AUTOSAVE_WINDOW = 90 -- maximum progress lost on a crash, in seconds
local LOCK_STALE_AFTER = 120 -- another server's lock considered dead
local LOCK_RETRIES = 5
local LOCK_RETRY_WAIT = 4
local FLUSH_DEADLINE = 25 -- BindToClose cuts at 30s; we leave earlier so we can log
local BUDGET_RESERVE = 8 -- UpdateAsync budget the autosave never spends
local store = DataStoreService:GetDataStore(STORE_NAME)
local jobId: string = if game.JobId ~= "" then game.JobId else "studio-" .. tostring(os.time())
export type Data = {
version: number,
coins: number,
level: number,
inventory: { string },
}
type Lock = { jobId: string, heartbeat: number }
type Record = { data: Data, lock: Lock? }
type Session = {
userId: number,
data: Data,
dirty: boolean,
saving: boolean,
lastSave: number,
}
local sessions: { [number]: Session } = {}
local closing = false
local SessionStore = {}
local function defaultData(): Data
return { version = 1, coins = 0, level = 1, inventory = {} }
end
local function keyFor(userId: number): string
return "u_" .. tostring(userId)
end
local function updateBudget(): number
return DataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.UpdateAsync)
end
-- Accepts the key in the new format (envelope with lock) and the old one (raw data).
local function toRecord(old: any, fallback: Data): Record
if old == nil then
return { data = fallback }
end
if old.data == nil then
return { data = old :: Data } -- migration from the raw format
end
return old :: Record
end
local function lockIsForeign(lock: Lock?): boolean
if lock == nil then
return false
end
return lock.jobId ~= jobId and (os.time() - lock.heartbeat) < LOCK_STALE_AFTER
end
-- Tries to take the session. Returns (record, blocked).
local function acquire(userId: number): (Record?, boolean)
local blocked = false
local record: Record? = nil
local ok, err = pcall(function()
store:UpdateAsync(keyFor(userId), function(old: any): Record?
local rec = toRecord(old, defaultData())
if lockIsForeign(rec.lock) then
blocked = true
return nil -- cancels the write: we do not steal a live session
end
rec.lock = { jobId = jobId, heartbeat = os.time() }
record = rec
return rec
end)
end)
if not ok then
warn("[SessionStore] acquire failed for " .. userId .. ": " .. tostring(err))
return nil, false
end
return record, blocked
end
-- Writes the session data. release=true drops the lock.
local function write(session: Session, release: boolean): boolean
local refused = false
local ok, err = pcall(function()
store:UpdateAsync(keyFor(session.userId), function(old: any): Record?
local rec = toRecord(old, session.data)
if lockIsForeign(rec.lock) then
refused = true
return nil -- another server took over: do not overwrite newer data
end
rec.data = session.data
rec.lock = if release then nil else { jobId = jobId, heartbeat = os.time() }
return rec
end)
end)
if not ok then
warn("[SessionStore] write failed for " .. session.userId .. ": " .. tostring(err))
return false
end
if refused then
warn("[SessionStore] write refused for " .. session.userId .. ": lock held by another server")
return true -- not a network error; a retry does not help
end
return true
end
local function writeWithRetry(session: Session, release: boolean, attempts: number): boolean
for i = 1, attempts do
if write(session, release) then
return true
end
if i < attempts then
task.wait(2 ^ (i - 1)) -- 1s, 2s, 4s
end
end
return false
end
function SessionStore.LoadAsync(player: Player): Data?
local userId = player.UserId
for _ = 1, LOCK_RETRIES do
local record, blocked = acquire(userId)
if record ~= nil then
local session: Session = {
userId = userId,
data = record.data,
dirty = false,
saving = false,
lastSave = os.clock(),
}
sessions[userId] = session
return session.data
end
task.wait(if blocked then LOCK_RETRY_WAIT else 2)
if player.Parent == nil then
return nil -- the player gave up before getting in
end
end
player:Kick("We could not open your save (session stuck on another server). Try again in a minute.")
return nil
end
function SessionStore.Get(userId: number): Data?
local session = sessions[userId]
return if session ~= nil then session.data else nil
end
function SessionStore.MarkDirty(userId: number)
local session = sessions[userId]
if session ~= nil then
session.dirty = true
end
end
function SessionStore.ReleaseAsync(player: Player)
local session = sessions[player.UserId]
if session == nil or session.saving then
return
end
session.saving = true
sessions[player.UserId] = nil
if not writeWithRetry(session, true, 3) then
warn("[SessionStore] LOST the save of " .. player.UserId .. " on the way out")
end
end
function SessionStore.StartAutosave()
task.spawn(function()
while not closing do
task.wait(1)
if updateBudget() > BUDGET_RESERVE then
local now = os.clock()
for _, session in sessions do
if session.dirty and not session.saving and (now - session.lastSave) >= AUTOSAVE_WINDOW then
session.saving = true
session.dirty = false
session.lastSave = now
task.spawn(function()
if not writeWithRetry(session, false, 3) then
session.dirty = true -- try again in the next window
end
session.saving = false
end)
break -- one write per tick: spreads the budget cost
end
end
end
end
end)
end
-- Goes inside game:BindToClose. Parallel + barrier + 25s deadline.
function SessionStore.FlushAll()
closing = true
local pending = 0
local done = 0
for _, session in sessions do
if not session.saving then
session.saving = true
pending += 1
task.spawn(function()
writeWithRetry(session, true, 2) -- 2 attempts: a long retry does not fit here
done += 1
end)
end
end
local t0 = os.clock()
while done < pending and (os.clock() - t0) < FLUSH_DEADLINE do
task.wait(0.1)
end
print(("[SessionStore] flush: %d/%d in %.2fs"):format(done, pending, os.clock() - t0))
end
return SessionStore--!strict
-- Wires SessionStore into the server lifecycle.
-- Where: a Script in ServerScriptService, beside the SessionStore ModuleScript.
local Players = game:GetService("Players")
local SessionStore = require(script.Parent:WaitForChild("SessionStore"))
local function onPlayerAdded(player: Player)
local data = SessionStore.LoadAsync(player)
if data == nil then
return -- LoadAsync already kicked, or the player left
end
local stats = Instance.new("Folder")
stats.Name = "leaderstats"
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Value = data.coins
coins.Parent = stats
stats.Parent = player
end
-- Example mutation: always touch memory and mark dirty.
local function addCoins(player: Player, amount: number)
local data = SessionStore.Get(player.UserId)
if data == nil then
return
end
data.coins += amount
SessionStore.MarkDirty(player.UserId)
local stats = player:FindFirstChild("leaderstats")
local coins = if stats then stats:FindFirstChild("Coins") else nil
if coins and coins:IsA("IntValue") then
coins.Value = data.coins
end
end
-- Players who joined before this script ran
for _, player in Players:GetPlayers() do
task.spawn(onPlayerAdded, player)
end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(function(player: Player)
SessionStore.ReleaseAsync(player)
end)
SessionStore.StartAutosave()
game:BindToClose(function()
SessionStore.FlushAll()
end)
return nil

