Roblox Session Locking: What Goes Wrong When You Write Your Own DataStore Layer

What happens to a player's inventory when two Roblox servers both believe they own it at the same time? If you have written your own DataStore layer, the last server to write wins — and whatever the other server knew is gone.
Most custom save systems work in Studio and in a quiet test server. The failures show up at scale, during shutdowns, teleports, and crashes, which is where a hand-rolled layer has usually had the least testing.
This guide walks through the four failure modes that session locking exists to prevent: concurrent writes, lost saves at shutdown, rollbacks, and orphaned locks. If you are new to the underlying API, start with our Roblox DataStores guide and come back once GetAsync and UpdateAsync feel familiar.
Session locking marks a player's save key as owned by one server's JobId. Any other server that tries to load that key must wait or refuse, so only one server can ever write that player's data at a time.
What Session Locking Actually Protects
A Roblox DataStore is a key-value store with no built-in idea of who owns a key. Any server in your experience can call SetAsync or UpdateAsync on Player_12345 at any moment, and DataStoreService will accept the write as long as you are inside your request budget.
Session locking adds that missing ownership layer. The save record carries a small lock field next to the player's data, and every read-modify-write checks that field before it commits anything.
A typical lock field holds three values:
- JobId. The
game.JobIdof the server that owns the session. Every autosave checks this before it writes. - PlaceId. The place the owning server is running. This helps when you debug cross-place teleports, because a lock held by your lobby place looks different from one held by a match place.
- Heartbeat timestamp. The last time the owning server confirmed it was alive. Another server uses this value to decide whether a lock has been abandoned.
Keep in mind that the lock lives inside the same key as the data. That matters because UpdateAsync gives you an atomic read-check-write on one key, and nothing stronger across two keys.
Failure One: Concurrent Writes From Two Servers
The classic duplication exploit needs only two servers and a few seconds of overlap. A player trades away a rare item on Server A, leaves, and joins Server B before Server A has finished its final save.
Server B loads the old record, which still contains the item. Server A then writes its post-trade record, and a minute later Server B autosaves its stale copy over it, so the item now exists in two inventories.
Many developers assume UpdateAsync solves this on its own. UpdateAsync only guarantees that your transform function sees the latest stored value at the moment of the write. It does nothing to stop Server B from holding a stale in-memory copy for the next ten minutes.
UpdateAsync makes a single write atomic, but it cannot stop a second server from loading a player, editing a stale copy in memory, and saving it later. Only a lock checked inside the transform blocks that.
With a session lock, Server B's load fails the ownership check because Server A's JobId is still in the record. Server B then retries on a short interval until Server A releases the lock with its final save, so the record Server B finally loads already includes the trade.
Failure Two: Lost Saves At Shutdown
When Roblox shuts a server down, whether for an update migration, because the last player left, or from a manual shutdown in the Creator Dashboard, the server gets a short window to run game:BindToClose callbacks. That window is capped at 30 seconds, and after that the process ends no matter what is still in flight.
A custom layer usually saves in Players.PlayerRemoving and hopes for the best. PlayerRemoving handlers are not guaranteed to finish before the server closes, and a server with 30 players fires 30 saves into the same narrow window.
Request budget makes this worse. Roblox documents per-server limits of roughly 60 + (player count × 10) Set and Update requests per minute, and a retry loop that hits throttling during shutdown can spend all 30 seconds waiting.
A shutdown-safe layer works through these steps in order:
- Stop accepting new sessions. Set a flag in BindToClose so that late PlayerAdded events do not start fresh loads.
- Release every active session in parallel. Start one release per player with task.spawn instead of saving players one at a time in a loop.
- Yield until the count hits zero. Track how many releases are still running and wait inside BindToClose. If you return early, the server closes while the writes are still in flight.
- Handle Studio separately. Studio playtest stops are abrupt, so saves are easy to lose or to send to the wrong store. Many teams gate this path with RunService:IsStudio() and use a separate test DataStore name.
Session locking also changes what a lost save costs you. Without a lock, a lost final save is a silent rollback. With one, the next server sees the unreleased lock and knows the previous session did not end cleanly.
Failure Three: Rollbacks After Teleports And Rejoins
Teleports are where unlocked layers lose the most data. When a player teleports from a lobby to a match place, the destination server often calls GetAsync before the source server's PlayerRemoving save has landed.
GetAsync also caches results on the server for about four seconds by default. A server that read the key recently can get that cached value back unless you pass a DataStoreGetOptions with UseCache set to false.
Rollbacks happen when a new server reads a player's save before the old server finishes writing it. A session lock forces the new server to wait until the old JobId releases the key, so it always loads the final record.
For instance, a player finishes a round, earns 500 coins, and teleports back to the lobby in under two seconds. Without a lock, the lobby loads the pre-round balance, the match server writes the new balance a moment later, and the lobby's next autosave erases the 500 coins.
The fix is the same retry loop from the concurrent-write case. If you pass player state through TeleportData or a server-side handoff, our guide to cross-platform save state explains why that payload should be treated as a hint and never as the source of truth.
The wait has to have a limit. A reasonable pattern retries every one to two seconds for 10 to 20 seconds, then shows the player a "your data is still saving" message instead of loading a stale record.
Failure Four: Orphaned Locks After A Crash
Sooner or later, a server will die before it releases its lock. A Roblox server can crash, run out of memory, or be terminated before BindToClose finishes, and the lock it wrote stays in the record indefinitely.
This is the stuck-lock problem. A custom layer without a staleness rule will lock that player out of your experience permanently, which is why the heartbeat field exists.
An orphaned lock is one whose owning server died without releasing it. Your loader should treat any lock whose heartbeat is older than a fixed stale window — commonly 10 to 30 minutes — as abandoned and claim it.
Picking the stale window means balancing two risks:
- Too short. A healthy but throttled server that missed one heartbeat loses its lock to another server, and the concurrent-write duplication you were trying to prevent comes back.
- Too long. A player whose server crashed has to wait out the full window before they can play again, and many of them will file a support ticket in the meantime.
For reference, ProfileService, the predecessor to ProfileStore, treated a session lock as dead after 30 minutes without an update. ProfileStore cuts the practical wait by using MessagingService to ask the owning server to save and release, so most handoffs never reach the stale timer.
Clock skew also matters. os.time() on two Roblox servers is UTC but is not guaranteed to agree to the second, so a stale window measured in minutes absorbs skew that a window measured in seconds would not.
How To Build A Session Lock On UpdateAsync
The core of a custom lock is one UpdateAsync call whose transform function returns nil when another server owns the key. Returning nil from the transform cancels the write, so the ownership check and the claim happen as one atomic operation.
Here is a minimal claim function that follows that pattern:
local DataStoreService = game:GetService("DataStoreService")
local store = DataStoreService:GetDataStore("PlayerData")
local STALE_SECONDS = 1800
local function tryClaim(userId)
local claimed = false
local ok, result = pcall(function()
return store:UpdateAsync("Player_" .. userId, function(record)
claimed = false -- the transform can run more than once
record = record or { version = 1, data = {} }
local lock = record.lock
local now = os.time()
if lock and lock.jobId ~= game.JobId and now - lock.heartbeat < STALE_SECONDS then
return nil -- a live server owns this key: cancel the write
end
record.lock = { jobId = game.JobId, placeId = game.PlaceId, heartbeat = now }
claimed = true
return record
end)
end)
return ok and claimed, result
end
The caller wraps tryClaim in the bounded retry loop described above and only hands the player their data once it returns true. Resetting claimed at the top of the transform matters, because DataStoreService can call the transform more than once when writes conflict.
Every autosave runs the same kind of transform with one change: if the stored JobId no longer matches game.JobId, the server has lost its session. It must stop writing immediately and kick the player, because any further save would overwrite the new owner's data.
Release follows the same pattern again. The final save writes the player's data and sets the lock field to nil in the same UpdateAsync call, so the next server never sees a released lock paired with stale data.
Store a schema version inside every record. A loader that checks record.version can add a lock field to legacy saves on first load, and an older server build that does not recognize the version can refuse to write instead of corrupting the record.
Autosave frequency affects both how fresh the heartbeat stays and how much request budget you use. An autosave every 60 to 120 seconds per player keeps the heartbeat well inside a 30-minute stale window. On a full 50-player server, that works out to roughly 25 to 50 writes per minute against a limit of about 560.
ProfileStore Vs A Custom DataStore Layer
ProfileStore is loleris's open-source module and the successor to ProfileService. It handles session locking, autosave, shutdown release, and lock handoff through MessagingService, and it has become a common community default for per-player saves.
A custom layer still makes sense in some cases. The comparison below shows where each approach fits:
| Concern | ProfileStore | Custom DataStore layer |
|---|---|---|
| Session locking | Built in, with MessagingService handoff | You build and test it yourself |
| Shutdown handling | BindToClose release handled by the module | You write and test the parallel release |
| Stuck-lock recovery | Most handoffs resolve without waiting on a timeout | Depends on your heartbeat and stale-window logic |
| Record shape | Profile table plus module metadata | Fully under your control |
| Debugging | Readable module source, shared behavior across many games | Every edge case is yours to find |
| Best fit | Most experiences with per-player saves | Fixed legacy schemas, shared global keys, strict data-shape rules |
Most teams should start with ProfileStore and write their own layer only when they can name a specific requirement it does not meet. If you do roll your own, treat the four failure modes above as acceptance tests that must pass before launch.
Choose ProfileStore for standard per-player saves, since it already handles locking, autosave, and shutdown release. Write a custom layer only for a requirement ProfileStore cannot meet, such as a fixed legacy record shape.
How Do You Know Your Save Layer Is Working?
You can only trust a save layer as far as the failures you have actually tested. Before launch, run each of these tests on a live server with at least two players, not only in Studio:
- Fast rejoin. Change a value, leave, and rejoin a different server within two seconds. The new server should wait for the lock and load the changed value.
- Teleport round-trip. Earn currency in a match place and teleport back right away. The lobby should never show the pre-match balance.
- Forced shutdown. Shut down all servers from the Creator Dashboard while a server is full. Every player's last change should survive, and every lock should be released.
- Simulated crash. Write a lock with an old heartbeat directly to a test key, then join. The loader should claim the stale lock and log that it did.
- Stolen session. Overwrite the lock with a fake JobId while a player is in game. The owning server should catch the mismatch on its next autosave, stop writing, and kick the player.
Also log every lock event (claim, wait, steal, release, and lost session) with the UserId and JobId. Those logs are the only way to tell a real rollback from a player misremembering their inventory, and they belong in the same reviewed module structure described in our Rojo and Git workflow guide.
Getting Your Save Layer Right Before Launch
Data loss is one of the bugs players forgive least, and one of the hardest to fix after launch, because the damaged records already exist. Session locking turns four silent failure modes into explicit states you can test.
If you are planning the wider server layout around your data module, our guide to Roblox server architecture covers where the save layer should sit relative to matchmaking, teleports, and game logic. For place-to-place flows, read it alongside our Roblox matchmaking guide so every teleport respects the lock.


