data-persistence › intermediate › DataStoreService, Players, RunService, HttpService
Session-Locked Player Data with UpdateAsync
Saves player data safely across servers by stamping the DataStore key with the owning server's JobId, so a second server refuses to load stale data and can't duplicate items on rejoin.
Problem
A player's save is written when they leave, but a load in another server can win the race. Join server A, trade an item away, rejoin fast enough on server B, and B reads the pre-trade value — the item now exists twice. The same race causes ordinary data regression whenever a player is bounced between servers by a teleport or a server shutdown.
Timestamp-based versioning detects the conflict but resolves it by discarding one side, which can silently delete real progress. Session locking prevents the second load from happening at all.
How it works
- One writer at a time. The profile stored under the player's key carries a
SessionJobIdfield alongside the data. A non-nilvalue means "some server currently owns this profile". - All access goes through
UpdateAsync.SetAsyncblindly overwrites, andGetAsyncserves a cache up to 4 seconds stale — neither can implement a lock.UpdateAsyncreads and writes atomically on the same key, so the check-and-claim cannot be interleaved by another server. - Claim on load. Inside the transform function, compare the stored
SessionJobIdtogame.JobId. If it isnil, or already equal to this server's JobId (a re-entry after a crash on the same server), write our JobId in and return the data — the lock is ours. - Back off on a live lock. If it holds a different JobId, the previous server has not released yet. Return
nilfrom the transform to abort the write, wait, and retry — typically a handful of attempts a few seconds apart while the old server finishes its leave-save. - Steal a stale lock. Store a heartbeat timestamp next to the JobId and refresh it on every autosave. If the timestamp is older than roughly 30 minutes the owning server crashed without releasing, so the lock is dead and can be taken. Without this, a hard crash locks a player out of their own data forever.
- Release on leave. The save performed in
Players.PlayerRemovingwrites the data and setsSessionJobId = nilin the sameUpdateAsynccall, so releasing the lock and persisting the data are one atomic step and cannot half-fail. - Autosave while held. Re-save every 60–120 seconds to bound crash losses and to refresh the heartbeat that keeps the lock from looking stale.
- Flush on shutdown.
game:BindToCloseruns the leave path for every remaining player. Roblox gives roughly 30 seconds; without this, a shutdown leaves every online player locked and unsaved.
Key code
local DataStoreService = game:GetService("DataStoreService")local store = DataStoreService:GetDataStore("PlayerProfiles_v1") local STALE_AFTER = 30 * 60 local function claim(userId) local aborted = false local ok, profile = pcall(function() return store:UpdateAsync("p_" .. userId, function(old) old = old or { data = { coins = 0 }, jobId = nil, heartbeat = 0 } local heldByOther = old.jobId ~= nil and old.jobId ~= game.JobId local stale = os.time() - (old.heartbeat or 0) > STALE_AFTER if heldByOther and not stale then aborted = true return nil -- abort the write; caller retries end old.jobId = game.JobId old.heartbeat = os.time() return old end) end) if aborted then return nil, "locked" end if not ok then return nil, "datastore-error" end return profileend local function release(userId, data) pcall(function() store:UpdateAsync("p_" .. userId, function(old) if old and old.jobId ~= game.JobId then return nil -- we no longer own it; do not clobber the new owner end return { data = data, jobId = nil, heartbeat = os.time() } end) end)end
Pitfalls
- Returning
nilfrom the transform aborts the wholeUpdateAsync. That is how you back off without writing — but it also means apcallsuccess does not imply a write happened. Track abort separately, as above. UpdateAsynccounts against the write budget (60 + 10 × players requests per minute for the server). Retry loops with no delay will burn it and start throttling every other save. Space retries several seconds apart and cap the attempts.- Never fall back to
SetAsyncwhen the lock check fails. It bypasses the lock entirely and reintroduces exactly the overwrite you were preventing. - Never release a lock you no longer own. Re-check the JobId inside the release transform; a stale-steal may have handed the profile to another server while your save was queued.
BindToCloseis capped at ~30 seconds and runs for all players at once. Save in parallel tasks, not a sequential loop, or the tail of a full server never persists.- Don't lock the player out on a
datastore-error. Distinguish "held by another server" (retry, then kick with a clear message) from "DataStore is down" (retry, then run the session in a no-save mode rather than deleting their profile). - A stale window that is too short steals live locks. Under 15 minutes risks stealing from a server whose autosave is merely throttled.
