Roblox DataStore Best Practices: How to Save Player Data Without Losing It

TL;DR - Key Takeaways
- • Every DataStore call is a network request that can fail; wrap each one in pcall and retry with backoff
- • If a player's data fails to load, never let them play on a blank profile, because it will be saved over the real one
- • Prefer UpdateAsync to SetAsync for player saves, and keep the transform function pure and non-yielding
- • Session locking stops two servers writing the same save, which is how rejoin bugs become item duplication
- • Save on leave, autosave on a staggered timer, and flush every player in BindToClose
- • Stamp every save with a schema version and migrate old saves forward on load
Of all the bugs I get asked to fix in other teams' Roblox games, data bugs are the ones that hurt most. A broken weapon costs you an afternoon. Lost progress costs you the player, usually permanently, and in a game with trading it can cost you an economy you cannot repair.
The frustrating part is that DataStore code almost always works in Studio. You test with one player, on one server, with no network trouble and no shutdowns, and every save lands. The failure modes only appear at scale: thousands of players, servers closing for updates, people leaving and rejoining within seconds. This guide covers the patterns we use at Santoz Studios so those failures never reach your players.
How DataStores Actually Behave
A DataStore is a key-value store hosted by Roblox. You read and write values, usually one key per player, through methods such as GetAsync, SetAsync, and UpdateAsync. Three properties of that system drive every decision in this guide.
- Every call is a network request. It yields, it takes time, and it can fail. Services have incidents, requests get throttled, and a call that worked a thousand times can error on the next one.
- Requests are budgeted. Each server gets a per-minute request budget that scales with its player count. Exceed it and requests queue, then fail. Read the live figures with
DataStoreService:GetRequestBudgetForRequestType()rather than hard-coding the documented ones, which Roblox revises from time to time. - Values have a size limit. A single key holds roughly 4 MB of serialised data. That sounds generous until you build a collection game, which we come back to below.
You will still see older tutorials warn about a six-second cooldown between writes to the same key. Roblox removed that cooldown, but per-key throughput limits remain, which is one more reason not to save on every change.
The examples below build up a small player data module. This is the shared setup they assume:
-- Script in ServerScriptService
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local DataStoreRetry = require(script.Parent.Modules.DataStoreRetry)
local PlayerStore = DataStoreService:GetDataStore("PlayerData")
local CURRENT_VERSION = 3 -- see Rule 5
local AUTOSAVE_INTERVAL = 90
local profiles: {[Player]: any} = {}
local function createDefaultData()
return { version = CURRENT_VERSION, currencies = { coins = 0, gems = 0 }, inventory = {} }
end
Rule 1: Assume Every Call Can Fail
The minimum standard is wrapping every DataStore call in pcall. The professional standard is retrying transient failures with a growing delay, logging each attempt, and reporting failure honestly to whatever called you, so it can decide what to do.
-- ServerScriptService/Modules/DataStoreRetry (ModuleScript)
local MAX_ATTEMPTS = 5
local BASE_DELAY_SECONDS = 1
local DataStoreRetry = {}
function DataStoreRetry.call(label: string, operation: () -> any): (boolean, any)
local lastError
for attempt = 1, MAX_ATTEMPTS do
local success, result = pcall(operation)
if success then
return true, result
end
lastError = result
warn(string.format("[DataStore] %s failed (attempt %d/%d): %s",
label, attempt, MAX_ATTEMPTS, tostring(result)))
if attempt < MAX_ATTEMPTS then
-- Back off: 1s, 2s, 4s, 8s
task.wait(BASE_DELAY_SECONDS * 2 ^ (attempt - 1))
end
end
return false, lastError
end
return DataStoreRetry
The backoff matters. When a DataStore incident hits and fifty players' saves all retry instantly, they burn through your request budget and make the problem worse. Waiting one, two, four, then eight seconds gives the service room to recover.
The bug that deletes progress
This is the single most destructive data bug on the platform, and I have found it in more inherited codebases than I can count. The load fails. The code shrugs, hands the player a fresh default profile, and lets them play. A minute later the autosave runs and writes that empty profile over their real one. Their progress is gone, and your logs show a successful save.
The fix is a rule rather than a technique: a profile that failed to load is never saved. If loading fails after your retries, kick the player with a clear message asking them to rejoin. It feels harsh. A player asked to rejoin is mildly annoyed; a player whose save was wiped does not come back.
-- A first version of loading. Rule 3 replaces GetAsync with a session-locked load.
local function onPlayerAdded(player: Player)
local success, saved = DataStoreRetry.call("load " .. player.UserId, function()
return PlayerStore:GetAsync(tostring(player.UserId))
end)
if not success then
-- Never fall back to default data here: it would be saved over the real profile.
-- No entry in profiles means no save path can ever write for this player.
player:Kick("We couldn't load your data. Your progress is safe, please rejoin.")
return
end
profiles[player] = { data = saved or createDefaultData() }
end
Players.PlayerAdded:Connect(onPlayerAdded)
Rule 2: Prefer UpdateAsync to SetAsync
SetAsync writes a value blindly. It does not know or care what is currently stored, so if two writes race, the last one wins and the other disappears without an error. UpdateAsync instead reads the current value and passes it to a transform function you supply. Whatever that function returns becomes the new value, and returning nil cancels the write entirely.
That gives you a place to make decisions with the stored value in front of you: refuse to overwrite a save another server owns, check a version, or merge rather than replace. Two rules apply to the transform function, and both catch people out:
- It cannot yield. No
task.wait, no other DataStore calls, nothing that pauses. Do that work before or after theUpdateAsynccall. - It may run more than once. If another server writes the key between your read and your write, Roblox calls your function again with the fresher value. It must be pure: compute the result from its arguments, and never award items, fire remotes, or change game state inside it.
One more detail that is easy to miss: the transform can also return a list of UserIds and a metadata table after the value. If you do not return them, any existing ones are cleared. The examples below return the player's UserId on every write so the key stays tagged with its owner, which matters for data deletion requests later.
UpdateAsync draws on both your read and write budgets, so it is not free. For player saves the safety is worth paying for every time.
Rule 3: Lock the Session
Picture a player on server A who leaves and immediately joins server B, which happens constantly when friends hop between servers. Server A starts saving as they leave. Server B starts loading as they arrive. If B's read lands before A's write, B loads the save from before the player's last session. When B saves later, everything they did on A is overwritten.
In a game without trading, that is lost progress. In a game with trading, it is a duplication exploit: trade an item away on server A, rejoin quickly, and server B loads a save that still has it. Players find this within days of launch, and duplicated items spread through an economy faster than you can patch.
Session locking closes the gap. The save records which server currently owns it. A server only takes a save that is unlocked, or whose lock has gone stale because its server crashed, and only ever writes a save it still owns.
-- A live server refreshes its lock on every autosave, so a lock this old
-- means the server that held it died without releasing it
local SESSION_STALE_AFTER = AUTOSAVE_INTERVAL * 5
local function tryAcquireSession(userId: number)
return PlayerStore:UpdateAsync(tostring(userId), function(saved)
saved = saved or createDefaultData()
local lock = saved.sessionLock
local now = os.time()
local isLockedElsewhere = lock ~= nil
and lock.jobId ~= game.JobId
and now - lock.updatedAt < SESSION_STALE_AFTER
if isLockedElsewhere then
return nil -- cancel: another live server still owns this save
end
saved.sessionLock = { jobId = game.JobId, updatedAt = now }
return saved, { userId }
end)
end
local function loadProfile(player: Player)
for _ = 1, 5 do
local success, saved = DataStoreRetry.call("acquire " .. player.UserId, function()
return tryAcquireSession(player.UserId)
end)
if not success then
return nil -- the store itself is failing
end
if saved and saved.sessionLock and saved.sessionLock.jobId == game.JobId then
saved.sessionLock = nil -- the lock lives in the store, not in gameplay data
return saved
end
task.wait(5) -- the previous server is probably mid-save; give it time
end
return nil
end
onPlayerAdded now calls loadProfile instead of GetAsync, and kicks on nil exactly as before. Writing is the mirror image: refuse if another server has taken ownership, and release the lock as part of the final write rather than in a separate request.
-- isFinal releases the lock in the same write that saves the data
local function writeProfile(player: Player, profile, isFinal: boolean)
local snapshot = table.clone(profile.data)
return PlayerStore:UpdateAsync(tostring(player.UserId), function(saved)
local lock = saved and saved.sessionLock
if lock and lock.jobId ~= game.JobId then
return nil -- we lost ownership; never overwrite another server's session
end
local toWrite = table.clone(snapshot)
toWrite.sessionLock = if isFinal then nil else { jobId = game.JobId, updatedAt = os.time() }
return toWrite, { player.UserId }
end)
end
Choosing the stale threshold is a trade-off. Too short, and a slow but healthy server can have a session stolen from under it. Too long, and a player whose server crashed waits longer to get back in. Several autosave intervals is a sensible middle ground.
If you would rather not maintain this yourself, ProfileStore, the open-source successor to ProfileService by loleris, implements session locking, autosaving, and a good deal more, and is widely used across the platform. Whether you adopt it or write your own, understand what your data layer guarantees before you build an economy on top of it.
Rule 4: Save at the Right Moments
There are three moments to save, and a robust game uses all three.
- When the player leaves.
Players.PlayerRemovingis the main save, and the one that releases the session lock. - On a timer. An autosave every one to two minutes caps how much progress a crash can cost. Spread players across the interval rather than saving everyone in the same second.
- When the server shuts down.
game:BindToCloseruns your callbacks before a server closes, and Roblox waits up to 30 seconds for them. Anything not written by then is lost, and queued DataStore requests die with the server.
local function releaseProfile(player: Player)
local profile = profiles[player]
if not profile or profile.isReleasing then
return -- never loaded, or another path is already saving it
end
profile.isReleasing = true
DataStoreRetry.call("release " .. player.UserId, function()
return writeProfile(player, profile, true)
end)
profiles[player] = nil
end
Players.PlayerRemoving:Connect(releaseProfile)
-- Autosave, spread across the interval instead of bursting the budget
task.spawn(function()
while true do
local players = Players:GetPlayers()
local gap = AUTOSAVE_INTERVAL / math.max(#players, 1)
for _, player in ipairs(players) do
local profile = profiles[player]
if profile and not profile.isReleasing then
DataStoreRetry.call("autosave " .. player.UserId, function()
return writeProfile(player, profile, false)
end)
end
task.wait(gap)
end
if #players == 0 then
task.wait(AUTOSAVE_INTERVAL)
end
end
end)
game:BindToClose(function()
local pending = 0
for _, player in ipairs(Players:GetPlayers()) do
pending += 1
task.spawn(function()
releaseProfile(player)
pending -= 1
end)
end
-- Saves run in parallel; Roblox allows roughly 30 seconds in total
while pending > 0 do
task.wait()
end
end)
Saving in parallel is the important part of that shutdown handler. Thirty players saved one after another, each with retries, can easily run past 30 seconds. Starting every save at once and waiting for all of them keeps the flush inside the window. The isReleasing flag stops a player being saved twice when PlayerRemoving and BindToClose both fire for them during a shutdown.
Do not save on every change. A write per coin collected burns budget and per-key throughput for no benefit, because the autosave and the leave save already cap your exposure. The exception is Developer Product purchases: ProcessReceipt should only report a purchase as granted once the grant is safely persisted, otherwise a crash can take a player's Robux without delivering what they paid for. Our monetisation guide covers that purchase flow.
Rule 5: Version Your Schema From Day One
Your data will outlive every version of the code that wrote it. A player who last played in your launch month will come back a year later carrying a save written by a schema you have since changed many times. You cannot delete their progress, and you cannot ship code that assumes the current shape. Our World Defenders TD case study describes what that looks like after years of live updates.
The pattern is straightforward: every save carries a version number, and every load runs the save forward through each migration until it matches the current version.
local MIGRATIONS = {
-- v1 to v2: coins moved into a currencies table alongside the new gems
[1] = function(save)
save.currencies = { coins = save.coins or 0, gems = 0 }
save.coins = nil
return save
end,
-- v2 to v3: inventory changed from a list of item ids to counts per id
[2] = function(save)
local counts = {}
for _, itemId in ipairs(save.inventory or {}) do
counts[itemId] = (counts[itemId] or 0) + 1
end
save.inventory = counts
return save
end,
}
local function migrate(save)
save.version = save.version or 1
while save.version < CURRENT_VERSION do
local step = MIGRATIONS[save.version]
if not step then
error(string.format("No migration from save version %d", save.version))
end
save = step(save)
save.version += 1
end
return save
end
Call migrate on every save as it loads, before it goes into profiles, so gameplay code only ever sees the current shape. Two habits keep this robust. Each migration only moves one version forward, so you write and test small steps rather than a converter from every historic shape to the current one. And a migration is never edited once it has shipped, because somewhere there is a save waiting to run through it exactly as it was.
The less obvious case is a save from the future. During an update, old and new servers run side by side for a while. A player can save on a new server at version 4, then land on an old server whose code only understands version 3. If that old server loads the save and writes it back, it silently strips whatever version 4 added. Add one more check to the acquire transform so outdated servers leave newer saves alone:
-- Inside tryAcquireSession's transform, before taking the lock
if (saved.version or 1) > CURRENT_VERSION then
return nil -- written by newer code; an outdated server must not touch it
end
The load fails, the player is asked to rejoin, and a rejoin will almost always land them on an updated server.
Store Less: References, Not Copies
Collection games have a data problem combat games do not: the save grows without limit. Every pet, tower, or item a player acquires is another entry, and a dedicated player accumulates thousands. If each entry carries its full definition, you approach the per-key size limit far sooner than you expect.
-- Wasteful: every pet copies its whole definition into the save
inventory = {
{ name = "Shadow Dragon", rarity = "Legendary", baseDamage = 340, description = "A dragon..." },
}
-- Compact: an id into a definition table that lives in code, plus per-pet state only
inventory = {
{ id = "shadow_dragon", rank = 4, power = 18250 },
}
The definition table lives in a ModuleScript, where it costs nothing per player. Only what is genuinely unique to that one item is persisted. We used exactly this approach in Hero Pets Simulator, where dedicated players hold enormous pet collections. A smaller save is also a faster write, and a faster write is less likely to fail partway through.
Plan for Recovery Before You Need It
Even with everything above in place, something will eventually go wrong: a bad migration, a bug that grants the wrong item, a player who reports lost progress. The time to build your recovery tools is before that happens.
- Version history. Roblox keeps previous versions of each key for 30 days, retrievable with
ListVersionsAsyncandGetVersionAsync. Build a small admin tool that restores a player's save from a chosen version. When a bad update corrupts data, you want that tool ready, not written in a panic. - Failure logging. Log every failed load and save with the UserId and the error. A rising failure rate is your earliest warning of a data problem, and it usually arrives before the first player complaint.
- UserId tagging. Pass the owner's UserId on every write, as the examples above do. It makes the right-to-erasure requests Roblox forwards to developers far easier to honour.
- A test copy of the experience. With Studio access to API services enabled, Studio reads and writes the same data stores your players use. Test destructive changes against a separate copy of the experience, never against live data.
A Checklist Before Launch
- Every DataStore call wrapped in pcall, with retries, backoff, and logging
- A failed load kicks the player instead of handing them default data
- Player saves use UpdateAsync, with a pure, non-yielding transform
- Session locking in place, either your own or ProfileStore
- Saves on leave, a staggered autosave, and a parallel flush in BindToClose
- A schema version on every save, migrations for each step, and a guard against newer saves
- Purchases persisted before ProcessReceipt reports them as granted
- An admin path to restore a save from a previous version
Unglamorous, and Non-Negotiable
Nobody plays your game because the saves are reliable. They stop playing the moment the saves are not. If your game is already live and you are not sure how your data layer handles every case above, that is worth finding out before your players do.
Data persistence is a core part of our Luau scripting services, and auditing an inherited data layer is often the first thing we do when we take over a project. For the wider picture of what belongs on the server and why, read our Roblox client-server architecture guide. If you would like a second pair of eyes on your own DataStore code, get in touch with us.
Shubham Sambher
Senior Developer, Santoz Studios
Shubham Sambher is Senior Developer at Santoz Studios with years of hands-on experience building Luau systems, custom game engines, and data pipelines on the Roblox platform. He specialises in data persistence and server-side architecture, the parts of a game players never notice until they fail.

