How to save Roblox player data without losing it: session locking, receipts and retries
Most lost-progress reports in Roblox games come from a handful of server situations, not from Roblox "deleting" data. A player leaves one server and joins another before the first has finished saving. A DataStore call fails during an outage and the error is ignored. A developer-product purchase is granted, the save fails, and Roblox calls the receipt handler again. Each one has a known fix. Here they are in the order they bite.
1. Two servers, one save: lock the session
When a player teleports or rejoins quickly, the old server may still be writing their data while the new server is loading it. If the new server loads the old copy, plays for ten minutes and saves, the old server's last write is lost, or the other way round.
The fix is a session lock: the save itself records which server owns it and when that server last touched it.
- On join, use
UpdateAsync(neverSetAsync) to read the save and, in the same transform, write your server'sJobIdand the current time into it, but only if no other server holds a fresh lock. - If another server holds a fresh lock, wait a few seconds and retry; if the lock is stale (that server crashed and has not touched it for several minutes), take it over.
- Every autosave and the final save on leave also go through
UpdateAsyncand check that your server still owns the lock. If it does not, stop saving: another server owns the player now. - On leave, write the data and clear the lock in the same update.
UpdateAsync matters because it reads and writes in one step, so two servers cannot both believe they own the save.
2. Outages and budgets: retry, then stop the player from losing more
DataStore requests can fail (service hiccups, request budgets). Wrap every call in pcall, retry with a short backoff a few times, and decide what happens if it still fails. On join, a failed load must not fall back to an empty profile that later overwrites the real one: kick the player with a friendly message or keep the session read-only. On leave, keep retrying the save while the server is alive and save everyone in game:BindToClose.
3. Robux purchases: grant once, and only after it is saved
MarketplaceService.ProcessReceipt is called for every developer-product purchase. Roblox keeps calling it (for example when the player rejoins) until you return PurchaseGranted (an Enum.ProductPurchaseDecision value). Two rules follow:
- Store the
PurchaseIdof every receipt you have granted inside the player's save. If the samePurchaseIdarrives again, returnPurchaseGrantedwithout granting twice. - Grant the item and save before returning
PurchaseGranted. If the save fails, returnNotProcessedYet, so Roblox tries again later instead of the player paying for nothing.
4. Never trust the client
Every RemoteEvent argument can be forged by an exploiter. Check types and ranges on the server (an item id is a short string, an amount is an integer from 1 to 99), rate-limit each remote per player, and keep prices, rewards and cooldowns on the server.
How Studwright tests this
Studwright is our kit of 16 Luau modules that implement these patterns. Its 66 tests run in a real Luau VM against a mock of the Roblox engine with a virtual clock, outage injection and a request budget. They cover two servers fighting over one save, a stale lock being taken over, a server that lost its lock and must stop saving, retried and failed receipts, 100,000 seeded loot rolls and the shipped example server end to end. The tests ship inside the zip, so you can run them yourself.
Using it looks like this:
local store = SaveData.new({ name = "PlayerData_v1", template = { Coins = 0, Level = 1 } })
local profile = store:Load(player) -- session-locked, retried; nil if the save is busy
local codes = Codes.new({ RELEASE = { reward = function(p) p.Data.Coins += 500 end } })
local ok, message = codes:Redeem(profile, textFromClient)
Run the free Studwright modules in your browser
Make the UI look finished: 9-slice images
Roblox buttons and panels look sharp at every size when their image is 9-sliced: set ScaleType to Slice and SliceCenter to the rectangle that may stretch, so the corners keep their shape. Three habits save time:
- Pack icons into an atlas and pick each one with
ImageRectOffsetandImageRectSize: one upload instead of dozens. - Ship icons in white too, then tint them with
ImageColor3for rarity colours or team colours. - Keep hover and pressed states as separate images, so the button reacts without scripts guessing colours.
Glintpane, our UI kit, has 77 icons in colour and white, two atlases that hold every icon (two uploads), 50 9-slice pieces and a Luau module that builds buttons and panels with the right SliceCenter.