references/sessions-and-limits.md
# Roblox DataStores: sessions, limits & metadata (current engine)
Depth behind the DataStores skill: session locking, versioning/metadata, ordered-
store pagination, request limits, and compliance.
## Session locking (preventing duplicate / clobbered data)
Players can occasionally be in two servers briefly (teleports, rejoin races). Two
servers loading and later saving the same key can lose writes. A *session lock*
makes a key owned by one server at a time:
1. On load, `UpdateAsync` the key to stamp it with this server's `JobId` and a
timestamp, but only if it's unlocked or the lock is stale (older than, say, the
`BindToClose` budget).
2. If it's locked by a live server, retry a few times, then either kick the player
or load read-only.
3. On save/leave, clear the lock as part of the same `UpdateAsync`.
`UpdateAsync` is the right primitive because its callback sees the current value, so
the check-and-set is atomic per request. (Production code often uses a vetted
open-source profile/session library that implements this correctly; if you roll your
own, test the teleport and rapid-rejoin cases.)
## Versioning & metadata
Regular `DataStore` keys support versioning and metadata; `OrderedDataStore` does
not (`DataStoreKeyInfo` is always `nil` there).
```lua
local DataStoreService = game:GetService("DataStoreService")
local store = DataStoreService:GetDataStore("PlayerData")
-- Attach UserIds (for content tracking) and custom metadata on write.
local options = Instance.new("DataStoreSetOptions")
options:SetMetadata({ schema = 2, region = "eu" })
pcall(function()
store:SetAsync(key, data, { player.UserId }, options)
end)
-- Read returns a second value, the DataStoreKeyInfo.
local ok, value, keyInfo = pcall(function() return store:GetAsync(key) end)
if ok and keyInfo then
print(keyInfo.Version, keyInfo.CreatedTime, keyInfo.UpdatedTime)
print(keyInfo:GetUserIds(), keyInfo:GetMetadata())
end
```
Important: when you set metadata or UserIds, you must re-supply them on every write
or they're cleared. In `UpdateAsync`, the callback receives `(currentValue, keyInfo)` and
returns up to `(newValue, userIds, metadata)`; return `keyInfo:GetUserIds()` and
`keyInfo:GetMetadata()` when you are not changing them. List past versions with
`ListVersionsAsync` and fetch one with `GetVersionAsync` to recover from a bad write.
A `schema`/version number stored alongside the data lets you **migrate** old saves:
on load, if `data.schema < CURRENT`, transform the table forward before use.
## Ordered stores: full pagination
```lua
local boards = DataStoreService:GetOrderedDataStore("Coins")
local ok, pages = pcall(function()
return boards:GetSortedAsync(false, 50) -- descending, page size 50
end)
if ok then
while true do
for rank, entry in ipairs(pages:GetCurrentPage()) do
-- entry.key (the data key), entry.value (the number)
end
if pages.IsFinished then break end
pages:AdvanceToNextPageAsync() -- yields; wrap loops in pcall in production
end
end
```
`GetSortedAsync(ascending, pageSize, minValue?, maxValue?)` — page size (1–100) caps how
many entries `AdvanceToNextPageAsync` fetches per request; `minValue`/`maxValue` must be
integers.
To show specific players' scores (friends, a party) without paging the whole board, read
up to 100 keys in one request with `boards:BatchGetAsync(keys)`. It is supported only on
ordered stores, returns a dictionary of `key → { value = n }`, omits missing keys, and
each requested key counts against the ordered read budget.
## Request limits & throttling
- Requests draw on two tiers of per-minute budget, both refreshed continuously: an
EXPERIENCE-level (game-wide) budget that scales with total concurrent users across the
whole experience, and a per-SERVER budget that scales with the players in that one
server. Since 2026-07-29 the in-game (game server) and Open Cloud APIs SHARE the
experience-level budget, so heavy external Open Cloud traffic can throttle in-game
requests and vice versa. Experience limits start at a 300/min baseline plus a per-CCU
multiplier per request type (reads scale fastest, lists slowest); `UpdateAsync` spends
from BOTH the read and write budgets on every call. Exceeding a budget queues requests
(each queue holds ~30) and then drops them with a 301-306 error. Per-server defaults are
configurable via `DataStoreService:SetRateLimitForRequestType`; check current headroom
with `GetRequestBudgetForRequestType`.
- Storage is a GAME-level limit, not per-key: total = 500 MB + 1 MB x lifetime user count
(any user who has ever joined), measured on the compressed latest version of each key.
Superseded versions and deleted keys do not count toward it. Store plain data; Roblox
compresses it, so pre-compressing only adds CPU cost.
- Per entry: store, key, and scope names are at most 50 characters; a value is at most
4,194,304 characters of serialized JSON (measure with `HttpService:JSONEncode`).
Per key across all servers: about 25 MB/minute of reads and 4 MB/minute of writes,
rounded up to the next KB per request. A hot shared key throttles as `KeyThrottled`
even when the experience budget has room.
- A failed write call does not prove the write was not applied; verify with an uncached
read before retrying a non-idempotent operation.
- `GetAsync` results are cached for about four seconds — an immediate re-read returns the
cached value, not necessarily the freshest. For a verification read after a write, pass
a `DataStoreGetOptions` with `UseCache = false` (it costs a real request).
- Practical rules: save on leave / `BindToClose` and on a periodic timer (e.g. every
60–120s), **not** on every value change. Coalesce many small changes into one
write. Use `UpdateAsync` so concurrent writers don't clobber.
- Treat every Async call as fallible: `pcall` + bounded retry with backoff. See
`https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits`
for the exact current numeric limits, which change over time.
## Right to be forgotten (RTBF)
You're responsible for deleting a user's data on request. Implement a removal path
(e.g. `RemoveAsync` on every store keyed by that `UserId`, plus any ordered-store
entries) and the Open Cloud DataStore API can perform deletions from outside the
game. Keep the list of stores/keys a player touches so deletion is complete.
## Open Cloud (out-of-game access)
The Open Cloud DataStore API lets trusted external tools read/write the same stores
with scoped API keys — useful for admin dashboards, moderation, and bulk migration.
Treat the API key as a secret; never embed it in client or game code.
SKILL.md
---
name: roblox-datastores
description: >
Persist player data in Roblox with DataStoreService: GetDataStore, GetAsync/
SetAsync/UpdateAsync/IncrementAsync wrapped in pcall, load-on-join and
save-on-leave plus BindToClose, retries, and OrderedDataStore leaderboards. Use
when saving or loading persistent data in a Roblox experience — when the user
mentions DataStore, DataStoreService, GetAsync, SetAsync, UpdateAsync, save player
data, or leaderboards. For general Luau scripting use roblox-luau.
---
# Roblox DataStores
Persist data across sessions in Roblox with `DataStoreService`: loading on join,
saving on leave and shutdown, safe updates, retries, and ordered stores for
leaderboards. Server-side only.
## When to use
- Use to save/load player progress (coins, inventory, levels), build persistent
leaderboards, or fix data loss, overwrites, and throttling.
- Use when server code calls `DataStoreService`, `GetDataStore`, `GetAsync`,
`SetAsync`, `UpdateAsync`, or `GetOrderedDataStore`.
**When *not* to use:** general scripting, services, remotes, the client/server
split → `roblox-luau`. High-frequency temporary state (matchmaking, per-round) →
memory stores (a different service). Engine-agnostic persistence theory →
`save-systems`.
## Core workflow
1. **Enable Studio access once.** In a published place, File → Experience Settings →
Security → *Enable Studio Access to API Services* (use a separate test place; Studio
hits the same live data as players). DataStores work only from server `Script`s,
never `LocalScript`s.
2. **Get a store, then read/write by key.** `DataStoreService:GetDataStore("Name")`;
key per player is usually `"Player_" .. player.UserId`.
3. **Wrap every call in `pcall`.** `GetAsync`/`SetAsync`/`UpdateAsync` are network
calls that can fail; an unguarded failure errors the thread and risks data loss.
4. **Load on `PlayerAdded`, save on `PlayerRemoving`, and also `BindToClose`.** A
leaving player and a shutting-down server both need a final save.
5. **Prefer `UpdateAsync` for read-modify-write** (multi-server safe) over `SetAsync`
(blind overwrite). On a failed load, do **not** overwrite with defaults — abort
the save so you don't wipe good data.
6. **Use `OrderedDataStore` for ranked data** (leaderboards) via `GetSortedAsync`.
Test by joining, changing data, rejoining, and confirming it persisted.
## Patterns
### 1. Load on join (pcall-guarded)
```lua
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local store = DataStoreService:GetDataStore("PlayerData")
local DEFAULT = { Coins = 0, Level = 1 }
Players.PlayerAdded:Connect(function(player)
local key = "Player_" .. player.UserId
local ok, data = pcall(function()
return store:GetAsync(key)
end)
if not ok then
-- Load FAILED (network). Do not treat as a new player; flag so we never save
-- over their real data with defaults.
warn("Load failed for", player.Name, data)
player:SetAttribute("DataLoaded", false)
return
end
player:SetAttribute("DataLoaded", true)
local profile = data or DEFAULT -- nil == genuinely new player
applyToLeaderstats(player, profile)
end)
```
### 2. Save with UpdateAsync (multi-server safe)
```lua
-- UpdateAsync reads the latest value, then writes what the callback returns.
-- The callback MUST NOT yield (no task.wait, no further Async calls inside it), and it
-- can run more than once if another server writes the key first, so keep it pure.
local function savePlayer(player)
-- Save only after a confirmed load. `~= true` also covers a player who leaves while
-- GetAsync is still in flight (attribute still nil), which `== false` would miss.
if player:GetAttribute("DataLoaded") ~= true then return end
local key = "Player_" .. player.UserId
local newData = gatherDataFor(player) -- a plain table of serializable values
local ok, err = pcall(function()
store:UpdateAsync(key, function(old)
-- merge/decide here; return nil to cancel the write
return newData
end)
end)
if not ok then warn("Save failed for", player.Name, err) end
end
```
### 3. Save on leave AND on shutdown
```lua
Players.PlayerRemoving:Connect(savePlayer)
-- BindToClose runs when the server shuts down; save everyone still in.
-- The server waits at most 30 seconds for it, so save in parallel and yield until done.
game:BindToClose(function()
local players = Players:GetPlayers()
local remaining = #players
if remaining == 0 then return end
for _, player in players do
task.spawn(function()
savePlayer(player)
remaining -= 1
end)
end
while remaining > 0 do task.wait() end
end)
```
### 4. Retry with backoff (transient failures)
```lua
local function withRetry(fn, attempts)
attempts = attempts or 3
for i = 1, attempts do
local ok, result = pcall(fn)
if ok then return true, result end
if i < attempts then task.wait(2 ^ i) end -- 2s, 4s, ... backoff
end
return false
end
local ok, data = withRetry(function() return store:GetAsync(key) end)
```
Retry only operations that are safe to repeat. A failed write is not proof the write did
not happen: the server may simply have missed the response. Re-running an `UpdateAsync`
that writes the full saved state is harmless; re-running `IncrementAsync` can add twice.
When the outcome matters, confirm it with an uncached read (`DataStoreGetOptions` with
`UseCache = false`) before retrying.
### 5. Increment a counter
```lua
-- IncrementAsync is a convenience for integer read-modify-write (still wrap it).
local ok, newTotal = pcall(function()
return store:IncrementAsync("Visits_" .. player.UserId, 1)
end)
```
### 6. Leaderboard with OrderedDataStore
```lua
local boards = DataStoreService:GetOrderedDataStore("Coins")
-- Write a player's score (call when it changes, not every frame).
pcall(function() boards:SetAsync("Player_" .. player.UserId, coins) end)
-- Read the top 10, descending.
local ok, pages = pcall(function()
return boards:GetSortedAsync(false, 10) -- ascending=false → highest first
end)
if ok then
for rank, entry in ipairs(pages:GetCurrentPage()) do
print(rank, entry.key, entry.value) -- entry.value is the number
end
end
```
## Pitfalls
- **Unhandled failure wipes progress** → always `pcall` Async calls; on a failed
*load*, mark the session and refuse to *save* so defaults never overwrite real data.
- **`SetAsync` race between servers** → two servers writing the same key can clobber
each other. Use `UpdateAsync` for read-modify-write so each write sees the latest.
- **Yielding inside the `UpdateAsync` callback** → the callback can't call
`task.wait` or other Async functions; compute the new value beforehand and return it.
- **Side effects inside the `UpdateAsync` callback** → Roblox re-runs the callback when
another server changed the key between read and write, so granting items, firing
remotes, or incrementing counters there can happen twice. Act after the call returns.
- **Saving a player who left mid-load** → `PlayerRemoving` can fire while `GetAsync` is
still yielding. Gate saves on "load succeeded", not on "load did not fail".
- **No `BindToClose` save** → players in the server at shutdown lose unsaved progress;
add `game:BindToClose` and wait for saves to finish within its budget.
- **Throttling / "too many requests"** → respect per-key and per-minute limits; don't
save on every value change. Batch and save on a timer / on leave. `GetAsync` is
cached for about four seconds, so immediate re-reads may be stale. Studio Run mode
uses its own lower static limits; Roblox recommends testing rate limits in Team Create.
- **Storing non-serializable values** → only JSON-serializable data persists: numbers,
strings, booleans, and tables with string/number keys. `Instance`s, `Vector3`,
`CFrame`, and functions do not — serialize them to plain tables first.
- **Testing without API access** → DataStore requests in Studio error (writes report
`StudioAccessToApisNotAllowed`) until *Enable Studio Access to API Services* is on (and
they don't work from a `LocalScript`).
- **`DataStoreKeyInfo` is nil for ordered stores** → `OrderedDataStore` doesn't
support versioning/metadata; use a regular `DataStore` when you need those.
## References
- For session locking (preventing duplicate data across servers), versioning/
metadata with `DataStoreSetOptions`, ordered-store pagination
(`AdvanceToNextPageAsync`), the key error codes and request limits, and
Right-to-be-Forgotten compliance, read `references/sessions-and-limits.md`.
## Related skills
- `roblox-luau` — services, instances, events, and the server/client model.
- `save-systems` — engine-agnostic serialization, slots, and migration.