Lua Scripting

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.

by Gustavo Cantino16 min readAdvanced

Produced with AI and checked by automated verification. This text carries no source list. No person read this text before publication. Editorial Policy

Typographic plate with the Lua Scripting category in large type, the advanced level below it, and a field of vertical lime green bars on a dark background.

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:

Script in ServerScriptService
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 queue

4. 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:

Script in ServerScriptService
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

ModuleScript in ServerScriptService (exact name: SessionStore)
lua
--!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
Script in ServerScriptService (sibling of the SessionStore module)
lua
--!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
In this article

Keep reading