
Roblox Datastores
- 51 installs
- 11 repo stars
- Updated August 3, 2026
- nonlooped/roblox-suite
Roblox DataStore patterns for persistent player data that prevent data loss, throttling, races, and quota exhaustion using UpdateAsync, session locking, and Open Cloud.
About
Covers production Roblox DataStore and OrderedDataStore patterns: atomic UpdateAsync writes, versioning and metadata for recovery, rate limits, player profile and session-locking, leaderboards, and Open Cloud integration. A developer uses it for any persistent player data, stats, inventory, settings, or cross-server state.
- Session-locking and profile patterns to prevent data races
- Versioning and metadata for data recovery and RTBF
Roblox Datastores by the numbers
- 51 all-time installs (skills.sh)
- +15 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #166 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nonlooped/roblox-suite --skill roblox-datastoresAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 51 |
|---|---|
| repo stars | ★ 11 |
| Last updated | August 3, 2026 |
| Repository | nonlooped/roblox-suite ↗ |
What it does
Roblox DataStore patterns for persistent player data that prevent data loss, throttling, races, and quota exhaustion using UpdateAsync, session locking, and Open Cloud.
Files
roblox-datastores
This skill delivers the authoritative, detailed knowledge required to implement robust, scalable, and safe persistent storage in Roblox experiences using Luau. LLMs often have outdated or incomplete information about the distinctions between datastore variants, the full power of versioning/metadata, configurable rate limits, precise caching behavior, and the exhaustive set of error conditions and quotas.
Primary official sources (consult these for the absolute latest):
- https://create.roblox.com/docs/cloud-services/data-stores
- https://create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores
- https://create.roblox.com/docs/cloud-services/data-stores/versioning-listing-and-caching
- https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits
- https://create.roblox.com/docs/cloud-services/data-stores/best-practices
- https://create.roblox.com/docs/cloud-services/data-stores/data-stores-manager
- https://create.roblox.com/docs/cloud-services/data-stores/observability
- Engine classes: DataStoreService, GlobalDataStore, DataStore, OrderedDataStore, DataStoreOptions, DataStoreKeyInfo, DataStoreSetOptions, etc. (full reference at https://create.roblox.com/docs/reference/engine)
Progressive disclosure in this skill:
- Start here in SKILL.md for high-level decision making, recommended workflows, and checklists.
- Read specific files in
references/for exhaustive technical details, tables, code samples, and edge cases when you need to implement or debug a particular aspect. - Use scripts/ for ready-to-adapt wrapper modules and utilities.
When to use this skill
Activate for:
- Implementing player data saving (progress, inventory, currency, settings, cosmetics).
- Creating or querying leaderboards (persistent sorted numeric data).
- Any cross-server or cross-place persistent state.
- Bulk operations, data migration, or cleaning up old data.
- Handling "right to be forgotten" (RTBF) / GDPR-style requests.
- Debugging data loss, throttling errors, quota issues, or inconsistent reads.
- Choosing between DataStores and MemoryStores (or combining them).
- Setting up monitoring via Data Stores Manager or the observability dashboard.
- Advanced patterns such as profile services, session locking, or safe concurrent updates.
Do not use (or only lightly reference) for purely ephemeral high-frequency data — see MemoryStoreService guidance in references and the vs-memory-stores doc.
Cross-reference:
- roblox-networking/SKILL.md for server-authoritative validation before any write.
- roblox-core/SKILL.md for service acquisition patterns, pcall discipline, and Luau serialization rules.
- roblox-gamepasses/SKILL.md when purchases affect persistent state (re-verify ownership on load).
- roblox/SKILL.md for the overall hub and Engine API link.
Core decision tree (read references/types-of-datastores.md for full details)
1. Is the data temporary/ephemeral, high-churn, or only needed while servers are active (queues, lobbies, rate counters, short caches)? → Use MemoryStoreService (queues, sorted maps, etc.). DataStores are overkill and more expensive/slower. See references and official comparison.
2. Do you need sorted numeric queries for leaderboards or rankings? → Use OrderedDataStore (via GetOrderedDataStore). Values must be integers. No versioning/metadata. Special GetSortedAsync + DataStorePages iteration. Limits differ (see references/limits...).
3. Do you need versioning for recovery/audit, user-defined metadata, key listing, or the richer DataStore API (ListKeys, ListVersions, GetVersionAtTime, RemoveVersion)? → Use the full DataStore created via GetDataStore with DataStoreOptions (this returns the modern DataStore class). Preferred for serious player data.
4. Simple key-value persistent storage without the above needs? → Use DataStoreService:GetDataStore() (returns a standard DataStore) or DataStoreService:GetGlobalDataStore() (legacy global store, scope "u"). For new work, prefer a named GetDataStore store.
Scopes vs prefixes (modern recommendation): For new work, prefer key prefixes (e.g. "profiles/User_1234") + ListKeysAsync filtering over legacy scopes. Scopes prepend to keys automatically. Use DataStoreOptions.AllScopes=true + empty scope string for cross-scope listing (see references).
Studio access: Never enable "Enable Studio Access to API Services" on production places. Use dedicated test experiences/universes.
Recommended production workflows
Player data profile pattern (most common)
See references/best-practices-and-gotchas.md for full examples and variations (including session locking and profile patterns).
High-level server flow (in a ModuleScript required by ServerScriptService scripts):
1. On PlayerAdded:
- Load with pcall + GetAsync (consider UseCache=false for critical fresh read after potential previous server issues).
- Merge with defaults.
- Store in a server-side table or profile object (e.g.
playerData[player.UserId] = data). - Apply any gamepass perks (cross-ref monetization skill) or other state.
- Optionally fire a Remote to client for initial UI sync (never trust client data).
2. During play:
- Mutate the in-memory profile.
- Periodically auto-save (every 30-60s) using UpdateAsync or SetAsync with proper transform.
- On important events (purchase, level up, trade), save immediately with UpdateAsync for safety.
3. On PlayerRemoving / BindToClose / character death:
- Final save with pcall.
- Clean up in-memory data.
- For BindToClose, yield up to ~30 seconds to finish final saves (Roblox gives a bounded grace period).
4. On errors during write:
- Use the verification pattern: after a failed write, immediately GetAsync with UseCache=false to see actual backend state before deciding retry/refund/rollback.
Use UpdateAsync with a pure (non-yielding) transform function for any value that can be concurrently modified by multiple servers. It reads-then-writes atomically from the perspective of the last writer.
Serialization rules (critical): Only nil, booleans, numbers (no inf/-inf/nan), strings (valid UTF-8), buffer, and tables of the above. No functions, no metatables on saved tables, no cycles. Use HttpService:JSONEncode on suspect data during development to preview what will actually be stored. See references/core-operations... .
Leaderboards with OrderedDataStore
Populate on relevant value changes or on save (use SetAsync or Increment). Query with GetSortedAsync(ascending, pageSize, min?, max?) → DataStorePages. Iterate pages with GetCurrentPage() + AdvanceToNextPageAsync(). Remember: only numbers; keys are strings; page iteration has its own limits; no versioning.
Display in a ScrollingFrame or via UI on demand. Cache top-N in a standard DataStore if you want fast global access without repeated queries.
See references for full page iteration example and limits differences.
Versioning and recovery (powerful safety net)
Every Set/Update/Increment on a standard DataStore (not Ordered) creates hourly-versioned backups: the first write to a key in a given UTC hour creates a new version snapshot, and subsequent writes in the same hour overwrite that hourly version. Versioned backups expire ~30 days after being superseded; the current version never expires.
- ListVersionsAsync(key, sort, minDate?, maxDate?, pageSize?)
- GetVersionAsync(key, version)
- RemoveVersionAsync(key, version) (permanent for that version)
- GetVersionAtTimeAsync(key, timestampMillis) — find the version current at a specific past time.
- To revert: read the desired version, then SetAsync the value + metadata back (this creates a new current version).
Also use the daily Snapshot Data Stores Open Cloud API before risky publishes.
The Data Stores Manager in Creator Hub lets you browse keys, view metadata/version history, compare versions, and revert (with proper permissions).
See references/versioning-metadata-recovery.md for complete code samples including the "revert to time of incident" pattern.
Caching control
By default: GetAsync results are cached locally for 4 seconds. Subsequent Gets within the window return cache and do not count against limits. Writes update the cache immediately.
For verification after writes (especially after errors), or when you suspect staleness due to other servers:
local opts = Instance.new("DataStoreGetOptions")
opts.UseCache = false
local value, info = store:GetAsync(key, opts)This always hits the backend and counts against quotas. Use sparingly but correctly.
See references/versioning-metadata-recovery.md (for listing, caching, serialization) and references/best-practices-and-gotchas.md .
Limits, quotas, throttling, and budgets (you must respect these)
There are experience-level limits (scale with concurrent users across the whole experience) and per-server limits (configurable via DataStoreService:SetRateLimitForRequestType).
UpdateAsync counts against both read and write budgets.
Use DataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.XXX) to check before heavy operations and wait in a loop if necessary.
Queues exist (size 30); when full, you get throttle errors (301-306 range or the newer *Throttled errors).
Full tables of every error code (101 KeyNameEmpty ... through all the ExperienceThrottled and GameServerThrottled variants) plus server-side errors are in references/limits-quotas-throttling-error-codes.md .
Observability dashboard (Creator Hub → Monitoring → Data Stores) shows real-time request counts by API/status, quota usage percentages, storage bytes vs limit.
Data Stores Manager shows total size vs storage limit (based on lifetime users), per-DS key counts, etc.
Pro tip: Call SetRateLimitForRequestType early in server init (once per type) to raise limits for migration scripts or busy servers. Base + (perPlayer * numPlayers). Constraints per type are documented.
Error handling discipline (non-negotiable)
Every datastore call must be inside pcall.
On failure:
- Log the specific error code/message.
- Decide: retry with backoff (exponential + jitter), skip, or take compensating action (e.g. refund virtual currency if a purchase-related write failed).
- For writes that may have partially succeeded on backend, use fresh Get (UseCache=false) to discover truth.
Never assume a failed Set/Update means "no change occurred."
RemoveAsync creates a "tombstone" (Get returns nil) but older versions remain queryable for 30 days (unless explicitly removed).
Management and monitoring
- Use Data Stores Manager (Creator Hub) for inspection, key search by prefix, version compare/revert, and scheduled deletion (with cooldown). The Manager's Revert button can revert an individual key to a previous version. The Restore button is for data stores that are marked for deletion, not for reverting a key's value. Restoring a deleted key during its cooldown requires calling
UpdateAsyncfrom the Engine API orUpdateDataStoreEntryfrom Open Cloud. - Observability dashboard for trends and quota forecasting.
- For bulk delete/migrate: Open Cloud Data Stores APIs or the official Batch Processor CLI/tool.
- Set up notifications for approaching/exceeding storage limits.
- Review usage regularly.
See references/versioning-metadata-recovery.md (covers listing/caching) and the manager/observability pages (linked in best-practices-and-gotchas.md).
Checklists
Before any production write:
- [ ] Wrapped in pcall
- [ ] Using UpdateAsync where races possible
- [ ] Providing userIds array for RTBF/GDPR tracking where appropriate
- [ ] Metadata supplied on every write (even if unchanged) when using full DataStore
- [ ] Value is serializable (test with JSONEncode during dev)
- [ ] Key naming consistent with prefixes for easy listing
On player join/load:
- [ ] Fresh load or cache-aware as appropriate
- [ ] Merge defaults safely
- [ ] Re-verify ownership on PlayerAdded (gamepasses, etc.) and re-apply any non-owned one-time consumable grants
- [ ] Client sync only of non-sensitive derived state
Before publishing risky data-logic changes:
- [ ] Take a daily snapshot via Open Cloud if available
- [ ] Test thoroughly on a copy experience
Ongoing:
- [ ] Monitor dashboard and manager
- [ ] Clean test/temporary data
- [ ] Use MemoryStores for anything that doesn't need to survive server death
- [ ] Prefer fewer data stores + larger cohesive objects + key prefixes
Common catastrophic mistakes this skill prevents
- Calling from client/LocalScript (immediate error + security hole).
- Storing functions, metatables, or non-serializable data.
- Using SetAsync for contended values (race condition → lost updates or duplication).
- Ignoring throttle errors and hammering (leads to dropped requests and player frustration).
- Hardcoding studio API access on live places.
- Creating hundreds of tiny data stores instead of organizing by key/prefix.
- Relying on cached Gets for post-write verification.
- Forgetting that OrderedDataStore has no versioning and only integers.
- Not handling the case where RemoveAsync has already been called (nil + tombstone).
- Exceeding storage quota without monitoring (leads to surprise costs and potential write failures).
Scripts folder usage
The scripts/ directory contains example ModuleScripts you can require or copy/adapt:
- SafeDataStore.lua — a robust wrapper with budget-aware waits, automatic retries, logging, and separate read/write paths.
- BudgetMonitor.lua — utilities to inspect and configure per-request-type budgets at startup.
Load them via require from your own modules when building production systems.
How to proceed in practice
1. Read the relevant references/ file(s) for the exact feature you are implementing. 2. Implement using the patterns and code samples here + in references. 3. Test budget behavior under load (use multiple test servers or the rate-limit setter for simulation). 4. Add observability and alerts early. 5. Profile storage growth with the Manager/Dashboard.
This skill, combined with the other Roblox skills in the toolset, will produce correct, efficient, maintainable, and resilient data persistence code.
For the authoritative class/property reference for any specific method, always cross-check https://create.roblox.com/docs/reference/engine (search for DataStoreService, GlobalDataStore, etc.).
Best Practices and Common Gotchas
Synthesized from the official "Best practices for data stores" page + all the error codes, limits, versioning, caching, and class reference material.
Official General Best Practices (verbatim emphasis)
- Create fewer data stores. Data stores behave like database tables. Fewer stores + related data grouped together lets you configure and operate them more efficiently.
- Use a single object for related data. Fetch/save a player's entire relevant state in one key when possible (respects the ~4 MB serialized limit, keeps versions consistent, reduces round-trips).
- Use key prefixes to organize. "profiles/User_1234", "inventory/User_1234", etc. Then ListKeysAsync("profiles/") gives you clean filtered results. Preferred over (or in addition to) legacy scopes for new work.
Optimization & Quota Hygiene
- Monitor constantly: Data Stores Dashboard (Creator Hub Monitoring) for request volume, status codes, and quota % usage. Data Stores Manager for actual stored size vs lifetime-user-based limit.
- Set up notifications for approaching or breaching storage limits.
- Delete aggressively after testing or events: use Manager "Mark for Deletion" (cooldown; keys can be restored via
UpdateAsyncfrom the Engine API orUpdateDataStoreEntryfrom Open Cloud during the cooldown), Batch Processor CLI, or Open Cloud bulk delete. - Prefer versioning + restore over "save a new key for every version".
- Use MemoryStores for anything that does not truly need to survive server restarts or long player absences.
- Clean up seasonal/temporary feature data promptly.
- Store player data under per-user keys rather than creating per-player data stores.
- Review usage trends regularly; sudden spikes usually indicate a code bug (e.g. saving inside a loop or on every input).
Caching Gotchas
- Cache is per DataStore instance (different scope or AllScopes setting = different cache).
- A write on one instance does not invalidate the cache on another instance pointing at the "same" logical store.
- After any write error, always verify with GetAsync(..., {UseCache = false}).
- Normal Gets inside the 4-second window are "free" (don't count against budgets) but can be stale.
Serialization & Data Shape Gotchas
- Only the documented types. inf/-inf/nan will fail or corrupt accessibility.
- UTF-8 strings only. Lone high bytes are fatal.
- Test suspect data with HttpService:JSONEncode before trusting a save path.
- Large objects or very deep tables increase latency and risk ValueTooLarge.
- When returning from UpdateAsync transform, be consistent about type and always return the userIds/metadata you want to keep.
Concurrency & Multi-Server Reality
- Multiple servers can (and will) be reading/writing the same keys for the same player (rejoins, multiple places, etc.).
- SetAsync is not safe for contended values.
- UpdateAsync + pure transform is the tool designed exactly for this.
- OnUpdate is deprecated — use MessagingService for cross-server pub/sub when you need near-real-time notifications.
Studio, Testing, and Lifecycle Gotchas
- Studio API access must be explicitly enabled per place (Security settings). Never do this on a live/production place.
- Data in Studio with API access enabled writes to the exact same backend as the live game. Use separate test experiences/universes.
- Studio Run mode uses its own static limits, which may be lower than
SetRateLimitForRequestTypesettings. Test rate-limit-sensitive logic in Team Create or a live test environment. - BindToClose gives you up to ~30 seconds to finish final saves — use it, but don't assume unlimited time.
- CharacterRemoving vs PlayerRemoving timing varies; have a robust final-save strategy.
Error & Throttle Handling Gotchas
- A "failed" write does not prove the backend did not apply the change. Always verify with a fresh read when the outcome matters.
- Throttle errors (301-306 family and the *Throttled family) mean you are either at experience quota or the per-server queue filled. Back off; do not tight-loop retry.
- Shutdown errors (4xx during experience close) are normal — your final saves in BindToClose may see them.
- "Key not found" after RemoveAsync is expected (tombstone).
- OrderedDataStore has its own error surface (page size, min/max integers, etc.).
Session Locking
For player data that must not be edited by two servers simultaneously (e.g. complex inventories, trades, or currency), use a session lock in a dedicated key.
Pattern: 1. When a server loads a player's profile, claim the lock by writing {ServerJobId = game.JobId, Expires = now + leaseSeconds} to locks/User_1234 using UpdateAsync. 2. The transform should only claim the lock if it is absent, expired, or already owned by this server. 3. Heartbeat-extend the lease while the player is present (UpdateAsync with same owner check). 4. On PlayerRemoving/BindToClose, stop extending, save the profile, then release the lock. 5. Another server that finds an active lock for a different server must either wait or load the player in read-only/safe mode.
Critical rules:
- Always use
pcall; never let a lock failure crash the load flow. - Keep lease durations short (5–15 seconds) and extend frequently.
- Always release the lock after final save, even on error paths where possible.
- Combine with
UpdateAsyncfor profile writes so race protection still exists if the lock fails.
For a battle-tested implementation, study established profile-service modules rather than building from scratch, but make sure you understand the lease/extend/release lifecycle.
Security & Privacy
- Never store secrets, auth tokens, or PII that you don't need.
- Always associate userIds on writes for data you may later need to delete under RTBF requests.
- Use the documented key template patterns + manual verification against live data (via Manager or ListKeys) before relying on automated deletion.
- Server-only access is mandatory. Any client-visible datastore key or value is a potential exploit vector.
When to Break the "Fewer Stores" Rule
Only when you have a genuine need for completely different rate-limit or permission characteristics, or when you are deliberately isolating experimental vs production data. In almost all cases one well-organized store (or a small number) per major domain (player data, economy, world state, leaderboards) is superior.
Summary Checklist (print this in your team docs)
- [ ] Using the right variant (Ordered only for numeric sorted queries, standard DataStore when you need versions/metadata).
- [ ] UpdateAsync for any contended player-owned value.
- [ ] pcall + specific error handling + fresh verification read after failures.
- [ ] Budget waiting + sensible server rate limits set at init.
- [ ] Consistent key prefixing + metadata/userIds.
- [ ] Regular dashboard + Manager reviews.
- [ ] Test data cleaned; snapshots before risky changes.
- [ ] MemoryStores used for transient high-churn data.
- [ ] No client access, no secrets in DS, RTBF-ready userIds.
Following these turns "it usually saves" code into systems that survive real production load for years.
Core Operations and Patterns
Covers GetAsync, SetAsync, UpdateAsync, IncrementAsync, RemoveAsync in depth, with decision guidance, serialization rules, pcall discipline, transform function constraints, and production patterns.
Sources: Official data-stores guide, DataStore/GlobalDataStore/OrderedDataStore class references, versioning guide, error codes page.
The Basic Operations (all yield, all must be pcalled)
All methods are on DataStore (and OrderedDataStore where supported). DataStore inherits from GlobalDataStore.
GetAsync(key, options?: DataStoreGetOptions)
- Returns (value, DataStoreKeyInfo?) or (nil, nil) if never written or tombstoned.
- Default behavior: 4-second local cache per data store instance. Hits within the window return cached data and do not count against request budgets.
- To force a fresh backend read (critical after failed writes or for verification): create DataStoreGetOptions with UseCache = false.
- When AllScopes is active, key must be supplied in "scope/key" form for the desired scope.
- KeyInfo (when present on v2 path) gives Version, CreatedTime, UpdatedTime, GetUserIds(), GetMetadata().
SetAsync(key, value, userIds?, options?: DataStoreSetOptions)
- Fast path for last-write-wins or low-contention data.
- Only consumes write budget.
- Creates a new version (hourly granularity).
- userIds table (array of numbers) recommended for any user-owned data (helps with RTBF requests and intellectual property tracking).
- options:SetMetadata(table) — you must supply metadata on every write (even if unchanged) or previous metadata is lost.
- On success returns the new version identifier (useful for later GetVersionAsync or RemoveVersionAsync).
Risk: If two servers Set the same key nearly simultaneously, one can overwrite the other without seeing the other's change.
UpdateAsync(key, transformFunction)
- The safest general-purpose write for contended data.
- Internally: reads current value + KeyInfo (consumes a read), calls your transform (which must not yield — no task.wait, no other async), then writes the result if non-nil (consumes a write).
- If another server updated the key between the read and the attempted write, the engine re-calls your transform with the newer current value. It keeps doing this until your transform succeeds in writing or returns nil (which aborts the update on this server).
- Transform signature:
function(currentValue, keyInfo?) return newValue, userIds?, metadata? end - Return nil as the first value to cancel (no write occurs).
- When using full DataStore + metadata, the transform should usually return the existing userIds/metadata unless you are intentionally changing them (otherwise they get cleared).
Strongly prefer UpdateAsync for currency, inventory counts, levels, achievement flags, etc.
IncrementAsync(key, delta?, userIds?, options?)
- Convenience for integer counters. Internally safe.
- Delta defaults to 1.
- Returns the new total.
- Does not support userIds on OrderedDataStore.
RemoveAsync(key)
- Marks the key as deleted (creates a tombstone version). Subsequent normal GetAsync returns nil.
- Older versions remain accessible via ListVersions/GetVersion until they naturally expire or are explicitly removed.
- On OrderedDataStore this is a true permanent delete (no versioning).
- Returns the pre-removal value + KeyInfo (or nil,nil if it was already gone).
After RemoveAsync, the key is inaccessible for normal operations but recoverable via versioning tools for 30 days (unless version is explicitly purged).
Serialization Rules (what you can actually store)
Data is stored as JSON under the hood.
Supported:
- nil
- boolean
- number (but never inf, -inf, or nan — they violate JSON and can make keys unreadable via Open Cloud)
- string (must be valid UTF-8; a lone byte >127 will fail)
- buffer
- table (arrays or dictionaries) containing only the above. No functions, no Instances, no other Roblox datatypes, no cycles.
Debugging tip: During development, take any data you plan to save and run it through HttpService:JSONEncode(data). If it succeeds and the result is reasonable size, it will almost certainly store. If it produces an error or "null" for parts of your data, fix the structure before saving.
Tables with numeric keys that have gaps or are used as dicts can have surprising behavior (numeric keys become strings in some representations). Prefer string keys for clarity when the data is more "record" than "array".
Maximum practical object size is documented in the limits page (serialized length). Exceeding produces ValueTooLarge (105).
SetAsync vs UpdateAsync Decision Tree
Use SetAsync when:
- Last writer wins is acceptable.
- Contention is impossible or extremely unlikely (e.g. a server writing only to its own private session key).
- You want the absolute fastest write path and only want to burn write budget.
Use UpdateAsync when:
- Multiple servers can plausibly touch the same key (almost all player-owned persistent data).
- You need to compute the new value based on the current backend value (add currency, merge inventory deltas, etc.).
- You want the engine to automatically retry the transform on conflict.
Many production "profile" systems wrap UpdateAsync and provide a clean API like profile:Increment("Gold", 50) or profile:Set("Level", 12) that internally use the right primitive.
Caching Interactions (see also versioning-metadata-recovery.md and best-practices-and-gotchas.md)
- Normal GetAsync → cached for 4s.
- Any Set/Update/Increment/Remove on the same data store instance immediately updates the local cache and resets the timer.
- Different DataStore instances (different scope strings, or one with AllScopes vs one without) have separate caches. This is a common source of "why did my change not appear?" bugs.
- After any write that returns an error, do not trust the cache. Perform a Get with UseCache=false to learn the truth from the backend before deciding what to do next (retry, compensate the player, etc.).
Production-Grade Wrapper Pattern (high level)
See the scripts/ folder for concrete examples (SafeDataStore.lua).
Typical features of a good wrapper:
- Central pcall + specific error classification (isThrottle, isShutdown, isPermanentBadData, etc.).
- Budget-aware waiting before operations.
- Configurable retry policy per operation type.
- Automatic metadata/userIds injection.
- Separate "critical" path that forces UseCache=false on verification reads.
- Logging that includes the exact key, operation, and error code.
- Support for both "fire and forget with retry queue" and "await with result" usage.
- Graceful degradation (e.g. give the player temporary offline currency that will be reconciled later).
Common Anti-Patterns to Avoid
- Saving on Heartbeat or every frame.
- Storing the entire player object or huge nested tables with lots of history.
- Using the same data store instance from multiple unrelated systems without understanding cache isolation.
- Assuming a successful pcall return from SetAsync means "no other server will ever overwrite this."
- Returning a different type or a huge table from an UpdateAsync transform on some code paths.
- Forgetting to return the existing userIds/metadata from your transform function.
- Using OrderedDataStore for anything except pure numeric rankings.
- Ignoring the 4-second cache when you actually needed the absolute latest value.
Master the distinction between Set and Update, always force fresh reads after questionable writes, and treat every datastore call as a potentially failing remote operation. This alone eliminates the majority of real-world data loss incidents.
Limits, Quotas, Throttling, and Error Codes
Primary source: https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits (contains the exhaustive tables this document summarizes and expands with usage advice).
Why This Matters
Data stores are a shared cloud resource. Roblox protects the service (and your experience) with multiple layers of limits:
- Experience-level quotas (scale with total concurrent users across all servers of the universe).
- Per-game-server limits (you can raise these with
DataStoreService:SetRateLimitForRequestTypeduring server initialization). - Per-key throttling in some cases.
- Hard queue sizes (when queues of 30 fill, requests are dropped with specific throttle errors).
Exceeding causes dropped requests, errors in the 301+ range (or the more modern *Throttled variants), and eventual player-visible failures (lost progress, failed purchases, broken leaderboards).
Always monitor with the Observability dashboard and Data Stores Manager. Use GetRequestBudgetForRequestType before bursts and implement waiting/retry logic.
Experience-Level Limits (formulas)
These are shared across the entire experience.
Standard Data Stores
- Read (GetAsync, GetVersionAsync, GetVersionAtTimeAsync, read portion of UpdateAsync): 250 + concurrentUsers × 40 per minute
- Write (SetAsync, IncrementAsync, write portion of UpdateAsync): 250 + concurrentUsers × 20 per minute
- List (ListDataStoresAsync, ListKeysAsync, ListVersionsAsync): 10 + concurrentUsers × 2 per minute
- Remove: 100 + concurrentUsers × 40 per minute
Ordered Data Stores
- Read (Get + read of Update): 250 + concurrentUsers × 40
- Write (Set/Increment + write of Update): 250 + concurrentUsers × 20
- List (GetSortedAsync): 100 + concurrentUsers × 2
- Remove: 100 + concurrentUsers × 40
Important: UpdateAsync always consumes both a read and a write budget for the relevant category.
Server-Level Limits (configurable)
Default (if you never call SetRateLimitForRequestType):
Standard data stores:
- StandardRead (GetAsync, GetVersion*, read portion of UpdateAsync): 60 + numPlayers × 40
- StandardWrite (SetAsync, IncrementAsync, write portion of UpdateAsync): 60 + numPlayers × 40
- StandardList (ListKeysAsync, ListVersionsAsync, ListDataStoresAsync): 5 + numPlayers × 2
- StandardRemove: 60 + numPlayers × 40
Ordered data stores (defaults differ — write/remove are much tighter):
- OrderedRead: 60 + numPlayers × 40
- OrderedWrite: 30 + numPlayers × 5
- OrderedList (GetSortedAsync): 5 + numPlayers × 2 — note:
GetRequestBudgetForRequestType(Enum.DataStoreRequestType.OrderedList)always returns0, so do not rely on budget inspection for this type. - OrderedRemove: 30 + numPlayers × 5
Note: the RemoveVersionAsync DataStoreRequestType enum entry is deprecated in the official limits table; prefer versioning recovery workflows in versioning-metadata-recovery.md over relying on that budget category.
You can (and should for migrations or high-traffic servers) call once per request type early in server startup:
local DSS = game:GetService("DataStoreService")
DSS:SetRateLimitForRequestType(Enum.DataStoreRequestType.StandardRead, 1000, 0) -- example aggressive for migration
DSS:SetRateLimitForRequestType(Enum.DataStoreRequestType.StandardWrite, 2000, 0)
-- etc. See constraints per type in the class reference and error-codes page.The formula the service uses after your call: rateLimit = baseLimit + (perPlayerLimit * currentNumPlayers)
Studio Run mode note: Requests made in Studio Run mode use a separate set of static limits that may be lower than those configured with SetRateLimitForRequestType. For realistic rate-limit testing, use Studio Team Create or a live test environment rather than Run mode.
There are documented upper/lower bounds per request type for the base and perPlayer arguments. Legacy request types such as GetSortedAsync are constrained to small defaults (base [0,5], perPlayer [0,2]); the modern v2 Standard* and Ordered* categories accept much larger values (base up to 10000, perPlayer up to 200 as of this writing). UpdateAsync cannot be configured with this API.
Budget Inspection API
DataStoreService:GetRequestBudgetForRequestType(Enum.DataStoreRequestType.StandardRead) etc.
Returns the remaining requests the current server can still make in the current minute before hitting the configured limit.
Important exception: For Enum.DataStoreRequestType.OrderedList, the API always returns 0 even though requests still consume quota. Do not wait on this budget or use it to gate GetSortedAsync calls.
Common pattern in heavy loops (migration, bulk processing):
local function waitForBudget(requestType)
while DSS:GetRequestBudgetForRequestType(requestType) <= 0 do
task.wait(1)
end
endThen call waitForBudget before each operation in a batch.
Listing Pagination Example
Both DataStoreService:ListDataStoresAsync(prefix?, pageSize?, cursor?) and DataStore:ListKeysAsync(prefix?, pageSize?, cursor?, excludeDeleted?) return DataStoreListingPages. Iterate with GetCurrentPage() + AdvanceToNextPageAsync(), and stop when IsFinished is true.
local function listAllKeys(store, prefix)
local all = {}
local success, pages = pcall(function()
return store:ListKeysAsync(prefix, 50)
end)
if not success then
warn("ListKeysAsync failed:", pages)
return all
end
while true do
for _, entry in ipairs(pages:GetCurrentPage()) do
table.insert(all, entry.KeyName)
end
if pages.IsFinished then break end
local ok = pcall(function()
pages:AdvanceToNextPageAsync()
end)
if not ok then break end
end
return all
endUse the same pattern for DataStoreService:ListDataStoresAsync. List calls consume StandardList budget.
Queues and Hard Drops
Each category has an internal queue of size 30. Requests are processed in order. When the queue is full, new requests are dropped with throttle errors (the classic 301 GetAsyncThrottle, 302 SetAsyncThrottle, 304 UpdateAsyncThrottle / TransformThrottle, 305 GetSortedThrottle, etc.).
Even if a request is accepted into the queue, extreme load can still result in later throttling at the experience or key level.
Per-Key Throughput Limits
In addition to request-count budgets, every individual key is subject to throughput limits based on the serialized bytes read/written over the last 60 seconds. Roblox rounds each request up to the next kilobyte.
- Read: 25 MB per minute per key
- Write: 4 MB per minute per key
Exceeding these manifests as DatastoreThrottled (data store-level) or KeyThrottled (key-level) errors. Large profiles, frequent full-object reads/writes, or saving big tables on Heartbeat are common causes. Mitigate by shrinking objects, caching reads, batching deltas, or using MemoryStores for hot transient data.
Full Error Code Reference (key excerpts + handling)
From the official table (study the complete page for every variant):
Client-side / validation errors (1xx):
- 101 KeyNameEmpty
- 102 KeyNameLimit (50 char max)
- 103/104 ValueNotAllowed / CantStoreValue (bad type returned from transform or non-serializable)
- 105 ValueTooLarge (serialized size limit; preview with HttpService:JSONEncode)
- 106/107 various OrderedDataStore GetSorted param errors (pageSize 1-100, min/max integers, min <= max)
Throttle / queue errors (3xx):
- 301 GetAsyncThrottle (and equivalents for Set, Increment, Update/Transform, GetSorted, Remove)
- These mean "queue was full when we tried to accept your request."
Shutdown / access errors (4xx):
- 401/402 DataModel or LuaWebService inaccessible during shutdown.
- 403 StudioAccessToApisNotAllowed (you forgot to enable it on a test place, or it is enabled on live — bad).
- 404/5xx various internal / corruption signals — retry later.
*Newer experience/server throttled errors (the Throttled family):** Hundreds of variants such as:
- StandardReadExperienceThrottled / StandardWriteExperienceThrottled / StandardListExperienceThrottled / StandardRemoveExperienceThrottled
- Same for Ordered*
- GameServerThrottled versions (per-server)
- Also GetVersionAsyncThrottle, ListKeysAsyncThrottle, ListVersionsAsyncThrottle, RemoveVersionAsyncThrottle, etc.
When you see any *Throttled, back off. Check budget. Consider raising your server rate limits (if you control them). Reduce frequency of operations. Use MemoryStores for hot paths.
Server-side errors (returned inside some failures):
- DatastoreDeleted, DatastoreThrottled, InternalServerError, KeyThrottled, KeyNotFound, Invalid* various, etc.
- "No pages to advance to" when calling AdvanceToNextPageAsync on the last page.
Metadata / attribute errors (5xx range in some listings):
- AttributeSizeTooLarge, UserIdLimitExceeded, AttributeFormatError (userIds must be numbers; metadata must be table).
Handling strategy (always): 1. pcall around every call. 2. Inspect the error string or code for the specific number/name. 3. For transient (throttle, internal, shutdown): retry with exponential backoff + jitter (e.g. local delay = math.min(base 2^(retryCount - 1), cap); task.wait(delay + math.random() delay * 0.5)). 4. For permanent (bad key name, bad value type, studio not enabled): log loudly and fail gracefully (don't spam retries). 5. After any write error, immediately attempt a fresh Get (UseCache=false) to learn the actual backend state. 6. For leaderboards or sorted pages, be prepared for "IsFinished" and handle the final page gracefully.
See the full error-codes page for the gigantic tables and the exact messages.
Storage Quota (separate from request quotas)
Calculated from lifetime unique users of the experience. Visible in Data Stores Manager as "Total Size" vs "Storage Limit".
Formula: Total latest-version storage limit = 100 MB + (1 MB × lifetime user count)
A lifetime user is any user who has joined the experience at least once. Only the latest version of each key counts toward this limit; deleted/replaced keys (even if still accessible through version APIs) do not count. Data stores marked for deletion via Open Cloud continue to count during their 30-day processing period.
Exceeding → estimated monthly overage costs shown. Can lead to operational pain.
Mitigation (best-practices):
- Fewer data stores.
- Larger cohesive objects per key instead of many small keys.
- Delete test/temporary/seasonal data promptly (Manager "Mark for Deletion" or Batch Processor / Open Cloud).
- Use MemoryStores for anything that can expire.
- Monitor the Storage Usage Bytes chart in the observability dashboard.
- Use versioning instead of creating new keys for every historical snapshot.
- Store player data under per-user keys rather than per-data-store.
Observability Dashboard Charts (use these)
- Storage Usage Bytes (current vs limit)
- Request Count by API (per-minute breakdowns of SetAsync, GetSortedAsync, etc.)
- Request Count by Status (200 OK + all the error families)
- Request by API × Status
- Read / Write / List / Remove Request Type Quota Usage (% against future limits)
- Filterable by Standard vs Ordered
Recent 3 minutes may be incomplete. Supports custom time ranges (up to 30 days).
Practical Advice
- Call SetRateLimitForRequestType once per type during server initialization, never in hot paths.
- For migration scripts that touch thousands of keys, dramatically raise the relevant read/write/list/remove limits, wait for budgets, and process page-by-page with careful error handling.
- Leaderboard GetSortedAsync page iteration consumes list budget at the page-size rate.
- Use the Data Stores Manager for human inspection and one-off reverts/deletes.
- Set up notifications for quota approach/exceed.
- Regularly review the dashboard for anomalies (sudden spikes often indicate a bug such as saving on every Heartbeat or per-frame).
Mastering these limits and the error surface is what separates toy saving code from production-grade systems that survive thousands of concurrent players and years of operation without data incidents.
Types of Data Stores
Official starting point: https://create.roblox.com/docs/cloud-services/data-stores and https://create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores
The DataStore Variants
1. DataStore (standard / recommended)
- Created via
DataStoreService:GetDataStore(name, scope?, options?: DataStoreOptions); withDataStoreOptionsthis returns the modernDataStoreclass (which extendsGlobalDataStore). - General-purpose key → value store.
- Value can be most serializable Luau data (numbers, strings, booleans, buffer, tables of the above; limited size in practice ~4 MB serialized recommended).
- Keys up to 50 characters.
- Supports
GetAsync,SetAsync,UpdateAsync,IncrementAsync,RemoveAsync, and the deprecatedOnUpdate. - Also supports the richer API when created through the modern path:
ListKeysAsync(prefix?, pageSize?, cursor?, excludeDeleted?)→ DataStoreListingPagesListVersionsAsync(key, sortDirection?, minDate?, maxDate?, pageSize?)→ DataStoreVersionPagesGetVersionAsync(key, version)GetVersionAtTimeAsync(key, timestampMillis)RemoveVersionAsync(key, version)- Full support for user-defined metadata via DataStoreSetOptions / DataStoreIncrementOptions and DataStoreKeyInfo:GetMetadata().
- UserIds array support for RTBF/GDPR tracking (passed on write, retrievable via KeyInfo).
- Versioning is automatic on writes (first write per UTC hour creates a snapshot; successive writes in same hour overwrite the hourly version).
- Versions expire ~30 days after being superseded. The current version never expires.
- Use this for most player data, auditability, rollback, and bulk key/version enumeration.
DataStoreService:GetGlobalDataStore() returns the legacy default "u"-scoped store (rarely used directly now). It is also a DataStore instance.
DataStoreOptions:
AllScopes(boolean): When true, the second argument to GetDataStore must be"". Enables listing keys across all scopes with their scope prepended (e.g. "global/User_1234" or "profiles/warrior_1234"). New keys created while AllScopes is active must include the explicit "scope/key" form.
2. OrderedDataStore
DataStoreService:GetOrderedDataStore(name, scope?)- Only stores integer values.
- No support for userIds, metadata, or versioning (DataStoreKeyInfo is always nil).
- Primary additional method:
GetSortedAsync(ascending: boolean, pageSize: int, minValue?, maxValue?)→ DataStorePages - pageSize: 1–100 (default 50 in many examples).
- Returns pages of
{key, value}entries sorted numerically. - Iterate with
pages:GetCurrentPage()andpages:AdvanceToNextPageAsync(). - Ideal exclusively for persistent leaderboards / high-score lists.
- Limits for list operations are different (and often tighter on the list side).
Rule from official guidance: If you need sorted queries → Ordered. If you need versioning/metadata/listing → standard DataStore. Simple cases can also use a standard DataStore via GetDataStore.
Scopes vs Modern Prefixes
Legacy scopes (second param to GetDataStore) automatically prepend to every key operation. Useful for isolation (e.g. "vip" scope).
Modern recommendation (best-practices page): Use fewer data stores + organize via key prefixes inside a single store (e.g. "profiles/User_1234", "inventory/User_1234"). Then use ListKeysAsync("profiles/") to filter.
For cross-scope needs in listing, enable AllScopes on a DataStoreOptions instance.
Comparison to Memory Stores (critical decision)
From https://create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores:
- DataStores: persistent across server lifetimes and player absences. Slower, subject to stricter quotas that scale with concurrent users. Best for permanent progress.
- MemoryStores (MemoryStoreService): fast, high-throughput, in-memory. Data expires after a configurable period, up to 45 days. No persistence across empty servers. Perfect for queues, lobbies, temporary caches, matchmaking state, live counters.
- Never use DataStores for purely session-scoped or rapidly changing transient data.
Storage Limits and Quotas Overview
Storage quota is per-universe and based on lifetime users.
Formula: Total latest-version storage limit = 100 MB + (1 MB × lifetime user count)
Only the latest version of each key counts; older versions and deleted/replaced keys do not count toward this limit (unless a data store is marked for deletion via Open Cloud, in which case it continues to count during its 30-day processing period). Exceeding the limit triggers estimated monthly costs in the Manager and can affect operations.
Monitor via:
- Creator Hub Data Stores Manager (total size, per-DS size/keys, key browser, version history, per-key Revert, mark-for-deletion with cooldown). The Manager's Restore button is for data stores marked for deletion, not for reverting an individual key's value.
- Observability dashboard (storage bytes, request counts by API and status, quota usage % for read/write/list/remove categories).
See the dedicated references/limits-quotas-throttling-error-codes.md for the full mathematical formulas (experience-level: 250 + concurrentUsers × N for various categories) and per-server defaults/configurable limits.
Open Cloud Contrast (for external tools)
The Engine API (inside experiences) is different from the Open Cloud REST Data Stores APIs. The latter require API keys with specific scopes (universe-datastores.*), support bulk/list operations from outside Roblox, and have their own authentication/rate limits. Use Engine for in-experience logic; Open Cloud + Batch Processor for admin tools, migrations, or RTBF processing.
Key takeaway: Choose the variant deliberately at creation time. You cannot easily convert an OrderedDataStore into a versioned DataStore later without migration code (see best-practices-and-gotchas.md and versioning-metadata-recovery.md for migration and recovery patterns).
Always test listing, versioning, and quota behavior on dedicated test experiences.
Versioning, Metadata, and Recovery
Detailed coverage of the versioning system, DataStoreKeyInfo, user-defined metadata, recovery workflows, snapshots, and the Data Stores Manager.
Drawn from https://create.roblox.com/docs/cloud-services/data-stores/versioning-listing-and-caching and class references for DataStore / GlobalDataStore.
How Versioning Works
Writes via SetAsync, UpdateAsync, and IncrementAsync on standard (non-Ordered) data stores automatically create versioned backups.
- The first write to a key in a given UTC hour creates a new version snapshot.
- Subsequent writes to the same key in the same UTC hour overwrite the data for that hourly version.
- Versioned backups expire approximately 30 days after they are superseded by a newer write.
- The current (latest) version of a key never expires.
OrderedDataStores have no versioning at all. RemoveAsync on an OrderedDataStore is a true permanent delete.
KeyInfo Object (returned by many reads)
When using the full DataStore path you often receive a second return value:
- Version (string identifier)
- CreatedTime, UpdatedTime (Unix ms since epoch)
- GetUserIds() → array of numbers you associated on write
- GetMetadata() → table you associated via SetOptions
On UpdateAsync the transform receives the current KeyInfo as the second argument so you can read (and choose to preserve or modify) userIds and metadata.
Rule: When calling SetAsync/UpdateAsync/IncrementAsync with metadata, you must always pass a (possibly unchanged) metadata table. Omitting it or passing nil will clear prior metadata.
Core Versioning APIs (on the standard DataStore path)
ListVersionsAsync(key, sortDirection?, minDateMillis?, maxDateMillis?, pageSize?)→ DataStoreVersionPages- SortDirection.Ascending or Descending (default Ascending in some contexts).
- Filter by time range.
- Returns pages of DataStoreObjectVersionInfo (Version, CreatedTime, IsDeleted, etc.).
GetVersionAsync(key, versionString)→ (value, KeyInfo) for that exact historical version.
GetVersionAtTimeAsync(key, timestampMillis)→ the version that was current at (or the closest before) the given time. Extremely useful for "the player says the bug happened around 3:42 UTC on the 12th".
RemoveVersionAsync(key, versionString)→ permanently deletes that specific historical version. It does not affect the current value or other versions, and it does not create a tombstone.
Normal RemoveAsync(key) creates a new tombstone current version (GetAsync returns nil) while leaving all previous versions intact for recovery.
Listing Keys and Data Stores
Both DataStoreService:ListDataStoresAsync(prefix?, pageSize?, cursor?) and DataStore:ListKeysAsync(prefix?, pageSize?, cursor?, excludeDeleted?) return DataStoreListingPages. Iterate with GetCurrentPage() and AdvanceToNextPageAsync() until IsFinished is true.
local function listAllKeys(store, prefix)
local all = {}
local success, pages = pcall(function()
return store:ListKeysAsync(prefix, 50)
end)
if not success then
warn("ListKeysAsync failed:", pages)
return all
end
while true do
for _, entry in ipairs(pages:GetCurrentPage()) do
table.insert(all, entry.KeyName)
end
if pages.IsFinished then break end
local ok = pcall(function()
pages:AdvanceToNextPageAsync()
end)
if not ok then break end
end
return all
endUse the same pattern for DataStoreService:ListDataStoresAsync. List operations consume StandardList budget.
Practical Recovery Workflow (example from official docs)
local maxDate = DateTime.fromUniversalTime(2020, 10, 9, 1, 42) -- time of the incident
local listSuccess, pages = pcall(function()
return store:ListVersionsAsync(DATA_STORE_KEY, Enum.SortDirection.Descending, nil, maxDate.UnixTimestampMillis)
end)
if listSuccess then
local items = pages:GetCurrentPage()
if #items > 0 then
local closest = items[1]
local success, value, info = pcall(function()
return store:GetVersionAsync(DATA_STORE_KEY, closest.Version)
end)
if success then
local setOptions = Instance.new("DataStoreSetOptions")
setOptions:SetMetadata(info:GetMetadata() or {})
local restored, restoreErr = pcall(function()
return store:SetAsync(DATA_STORE_KEY, value, info:GetUserIds() or {}, setOptions)
end)
if not restored then
warn("Restore SetAsync failed:", restoreErr)
end
-- The restore itself creates a new current version with the old data.
end
end
endYou can also do this interactively through the Data Stores Manager in Creator Hub (select key → select old version → Compare or Revert). Note that the Manager's Revert button reverts a key to a previous version by creating a new current version with the old data; this is equivalent to the SetAsync restore pattern above. The Restore button is for data stores marked for deletion, not for reverting key values.
Metadata Use Cases
- Tagging data for analytics or cleanup ("EventSummer2026", "BetaTester").
- Storing extra context that travels with the key (source of the data, schema version).
- Assisting automated RTBF / right-to-be-forgotten processing (combined with the userIds array).
Metadata is returned on Get, GetVersion, etc., and must be round-tripped on writes if you want to keep it.
Snapshots (Open Cloud)
Before any risky publish that changes data storage logic, take a manual snapshot of all data stores via the Snapshot Data Stores Open Cloud API (daily automated snapshots may also be available).
A snapshot taken at 3:29 UTC protects all data written before that time even if your 3:30 publish immediately corrupts data for keys written in the following minutes.
Data Stores Manager Capabilities (human + permissioned ops)
- Browse all data stores (filter by prefix).
- Drill into a data store → list keys (prefix filter).
- Inspect a key: current value, metadata, full version history, last update time, status (deleted or not).
- Compare any two versions side-by-side.
- Revert a key to a previous version (requires Edit Data Stores permission).
- Mark data store or individual key for deletion (cooldown period; a deleted key can be restored during the cooldown by calling
UpdateAsyncfrom the Engine API orUpdateDataStoreEntryfrom Open Cloud).
Permissions (group experiences): View Data Stores, Edit Data Stores, Delete Data Stores, plus the broad "Edit all group experiences".
Best Practices Around Versioning & Recovery
- Design keys so that a single key = a coherent self-contained object (player profile, not "gold for player X + separately inventory for player X"). This makes version restores consistent.
- Use versioning instead of creating new keys for every historical save (saves storage quota and key count).
- Before major refactors of your save/load code, take a snapshot.
- Train your team on the Manager for quick human triage of player reports ("I lost my items at 14:20").
- Document the schema version inside metadata so that on restore you know whether the old data is still compatible with current code.
- For very large experiences, combine versioning with the Batch Processor / Open Cloud for bulk recovery or inspection when the Manager becomes impractical (experiences with >100 data stores may hide some aggregate numbers).
Versioning is one of the strongest safety nets Roblox gives you for free. Use the List/Get/RemoveVersion and GetVersionAtTime APIs, the Manager UI, and snapshots proactively rather than only after a disaster.
--!strict
--[[
BudgetMonitor.lua
Utilities for inspecting and configuring DataStore request budgets at server startup.
Call early in a Script under ServerScriptService (once per server).
Example:
local BudgetMonitor = require(...)
BudgetMonitor.configureForBusyServer()
BudgetMonitor.printAllBudgets()
]]
local DataStoreService = game:GetService("DataStoreService")
local BudgetMonitor = {}
local REQUEST_TYPES = {
"StandardRead", "StandardWrite", "StandardList", "StandardRemove",
"OrderedRead", "OrderedWrite", "OrderedRemove",
-- Note: GetVersionAsync/GetVersionAtTimeAsync count as StandardRead, not separate budget categories.
-- OrderedList is intentionally omitted: GetRequestBudgetForRequestType always returns 0 for it.
}
local NO_BUDGET_INSPECTION = {
OrderedList = true,
}
function BudgetMonitor.printAllBudgets()
print("=== Current DataStore Request Budgets ===")
for _, name in ipairs(REQUEST_TYPES) do
local enumItem = Enum.DataStoreRequestType[name]
if enumItem then
local budget = DataStoreService:GetRequestBudgetForRequestType(enumItem)
print(string.format("%-20s : %d", name, budget))
end
end
print("OrderedList : budget inspection always returns 0")
end
function BudgetMonitor.configureForBusyServer()
-- Example aggressive but reasonable settings for a popular game or migration server.
-- Tune based on your actual concurrent player count and workload.
-- Call this ONLY during server initialization, once per type.
local function safeSet(requestTypeName, base, perPlayer)
local enumItem = Enum.DataStoreRequestType[requestTypeName]
if enumItem then
local ok, err = pcall(function()
DataStoreService:SetRateLimitForRequestType(enumItem, base, perPlayer)
end)
if ok then
print("Configured", requestTypeName, "base=", base, "perPlayer=", perPlayer)
else
warn("Failed to set rate limit for", requestTypeName, err)
end
end
end
-- These are examples — respect the per-type constraints documented in the class reference.
-- The per-player StandardWrite value here matches the Roblox default (40). Lowering it reduces
-- per-server burst but can cause write throttling under load; raising it increases headroom
-- but also raises the ceiling on how much a single server can consume.
safeSet("StandardRead", 2000, 50)
safeSet("StandardWrite", 1500, 40)
safeSet("StandardList", 300, 5)
safeSet("StandardRemove", 1000, 20)
safeSet("OrderedRead", 1500, 40)
safeSet("OrderedWrite", 1000, 20)
-- OrderedList: SetRateLimitForRequestType accepts the enum, but GetRequestBudgetForRequestType
-- always returns 0 for it, so the limit cannot be inspected at runtime (see NO_BUDGET_INSPECTION above).
safeSet("OrderedList", 150, 3)
safeSet("OrderedRemove", 800, 15)
end
function BudgetMonitor.waitForAnyBudget(requestTypeName, timeout)
local enumItem = Enum.DataStoreRequestType[requestTypeName]
if not enumItem then return false end
if NO_BUDGET_INSPECTION[requestTypeName] then
warn("Cannot wait for budget of " .. requestTypeName .. "; GetRequestBudgetForRequestType always returns 0 for this type.")
return false
end
local start = os.clock()
while DataStoreService:GetRequestBudgetForRequestType(enumItem) <= 0 do
if os.clock() - start > (timeout or 30) then
return false
end
task.wait(0.25)
end
return true
end
return BudgetMonitor
--!strict
--[[
SafeDataStore.lua
A production-oriented, server-only wrapper around DataStoreService that handles:
- Server-side guard (fails fast if required from client)
- Key/userIds/value validation before writes
- Consistent pcall + error classification
- Budget-aware waiting (rechecked before every attempt)
- Exponential backoff + jitter retry for transient errors
- Automatic metadata/userIds round-trip on SetAsync/IncrementAsync
- Existing metadata/userIds injection for UpdateAsync unless explicitly overridden
- RemoveAsync, ListKeysAsync, ListVersionsAsync wrappers
- Fresh verification reads after writes
- Logging hooks
Usage (server only):
local SafeDS = require(path.to.SafeDataStore)
local store = SafeDS.new("PlayerData", "") -- or with options
local data, info = store:getAsync("User_" .. userId)
store:updateAsync("User_" .. userId, function(current, keyInfo)
current = current or {}
current.Gold = (current.Gold or 0) + 50
return current -- existing userIds/metadata are preserved automatically
end)
IMPORTANT: Transform functions passed to updateAsync must not yield.
]]
local DataStoreService = game:GetService("DataStoreService")
local HttpService = game:GetService("HttpService")
local RunService = game:GetService("RunService")
if RunService:IsClient() then
error("SafeDataStore must be required on the server", 2)
end
local SafeDataStore = {}
SafeDataStore.__index = SafeDataStore
local MAX_KEY_LENGTH = 50
local MAX_USER_IDS = 50
local MAX_VALUE_SIZE = 4 * 1024 * 1024
local function isTransientError(err)
if type(err) ~= "string" then return false end
return err:match("Throttle") or err:match("throttled") or
err:match("Internal") or err:match("internal") or
err:match("RequestRejected") or err:match("DataModelNoAccess")
end
local function validateKey(key)
if type(key) ~= "string" or #key == 0 or #key > MAX_KEY_LENGTH then
return false, string.format("key must be a non-empty string up to %d chars", MAX_KEY_LENGTH)
end
return true
end
local function validateUserIds(userIds)
if userIds == nil then return true end
if type(userIds) ~= "table" then
return false, "userIds must be a table"
end
if #userIds > MAX_USER_IDS then
return false, string.format("userIds array exceeds %d entries", MAX_USER_IDS)
end
for _, id in ipairs(userIds) do
if type(id) ~= "number" then
return false, "userIds must be numbers"
end
end
return true
end
local function validateValueSize(value)
local ok, encoded = pcall(HttpService.JSONEncode, HttpService, value)
if not ok then
return false, "value is not JSON-serializable"
end
if #encoded > MAX_VALUE_SIZE then
return false, string.format("serialized value exceeds %d bytes", MAX_VALUE_SIZE)
end
return true
end
local function mergeDefaults(existing, override)
if override ~= nil then return override end
if existing ~= nil then return existing end
return {}
end
function SafeDataStore.new(name, scope, options)
local self = setmetatable({}, SafeDataStore)
self._store = DataStoreService:GetDataStore(name, scope or "", options)
self._name = name
self._scope = scope or ""
self._maxRetries = 3
self._backoffBase = 0.5
self._backoffCap = 8
return self
end
function SafeDataStore:_log(level, msg, ...)
-- Replace with your logging system (AnalyticsService, etc.)
print(string.format("[SafeDS:%s] %s: %s", level, self._name, string.format(msg, ...)))
end
function SafeDataStore:_backoff(attempt)
local exponential = self._backoffBase * (2 ^ (attempt - 1))
local capped = math.min(exponential, self._backoffCap)
local jitter = math.random() * capped * 0.5
return capped + jitter
end
function SafeDataStore:_waitForBudget(requestType, maxWait)
maxWait = maxWait or 30
local start = os.clock()
while DataStoreService:GetRequestBudgetForRequestType(requestType) <= 0 do
if os.clock() - start > maxWait then
return false
end
task.wait(0.25)
end
return true
end
function SafeDataStore:getAsync(key, useCache)
local ok, err = validateKey(key)
if not ok then return nil, nil, err end
local opts
if useCache == false then
opts = Instance.new("DataStoreGetOptions")
opts.UseCache = false
end
local requestType = Enum.DataStoreRequestType.StandardRead
for attempt = 1, self._maxRetries do
if not self:_waitForBudget(requestType) then
self:_log("WARN", "Budget timeout on GetAsync for key %s", key)
return nil, nil, "BudgetTimeout"
end
local success, value, keyInfo = pcall(function()
return self._store:GetAsync(key, opts)
end)
if success then
return value, keyInfo
end
self:_log("ERROR", "GetAsync failed (attempt %d) key=%s err=%s", attempt, key, tostring(value))
if attempt < self._maxRetries and isTransientError(value) then
task.wait(self:_backoff(attempt))
else
return nil, nil, value
end
end
return nil, nil, "MaxRetriesExceeded"
end
function SafeDataStore:_getExistingInfo(key)
local value, info = self:getAsync(key, true)
return info
end
function SafeDataStore:setAsync(key, value, userIds, metadataTable)
local ok, err = validateKey(key)
if not ok then return false, err end
ok, err = validateUserIds(userIds)
if not ok then return false, err end
ok, err = validateValueSize(value)
if not ok then return false, err end
local needsExisting = userIds == nil or metadataTable == nil
local existingInfo
if needsExisting then
existingInfo = self:_getExistingInfo(key)
end
local finalUserIds = mergeDefaults(existingInfo and existingInfo:GetUserIds() or nil, userIds)
local finalMetadata = mergeDefaults(existingInfo and existingInfo:GetMetadata() or nil, metadataTable)
local setOptions = Instance.new("DataStoreSetOptions")
setOptions:SetMetadata(finalMetadata)
local requestType = Enum.DataStoreRequestType.StandardWrite
for attempt = 1, self._maxRetries do
if not self:_waitForBudget(requestType) then
self:_log("WARN", "Budget timeout on SetAsync for key %s", key)
return false, "BudgetTimeout"
end
local success, result = pcall(function()
return self._store:SetAsync(key, value, finalUserIds, setOptions)
end)
if success then
return true, result
end
self:_log("ERROR", "SetAsync failed (attempt %d) key=%s err=%s", attempt, key, tostring(result))
if attempt < self._maxRetries and isTransientError(result) then
task.wait(self:_backoff(attempt))
else
return false, result
end
end
return false, "MaxRetriesExceeded"
end
function SafeDataStore:updateAsync(key, transformFn)
-- transformFn receives (currentValue, keyInfo?) and must return (newValue, userIds?, metadata?) or nil to cancel.
-- The transform MUST NOT YIELD (no task.wait, no datastore calls, no async work).
local ok, err = validateKey(key)
if not ok then return false, err end
if type(transformFn) ~= "function" then
return false, "transformFn must be a function"
end
local wrappedTransform = function(currentValue, keyInfo)
local userResult, userIds, metadata = transformFn(currentValue, keyInfo)
if userResult == nil then
return nil
end
local finalUserIds = mergeDefaults(keyInfo and keyInfo:GetUserIds() or nil, userIds)
local finalMetadata = mergeDefaults(keyInfo and keyInfo:GetMetadata() or nil, metadata)
return userResult, finalUserIds, finalMetadata
end
local requestTypeRead = Enum.DataStoreRequestType.StandardRead
local requestTypeWrite = Enum.DataStoreRequestType.StandardWrite
for attempt = 1, self._maxRetries do
if not self:_waitForBudget(requestTypeRead) or not self:_waitForBudget(requestTypeWrite) then
self:_log("WARN", "Budget timeout on UpdateAsync for key %s", key)
return false, "BudgetTimeout"
end
local success, newValue, keyInfo = pcall(function()
return self._store:UpdateAsync(key, wrappedTransform)
end)
if success then
return true, newValue, keyInfo
end
self:_log("ERROR", "UpdateAsync failed (attempt %d) key=%s err=%s", attempt, key, tostring(newValue))
if attempt < self._maxRetries and isTransientError(newValue) then
task.wait(self:_backoff(attempt))
else
return false, newValue
end
end
return false, "MaxRetriesExceeded"
end
function SafeDataStore:incrementAsync(key, delta, userIds, metadataTable)
local ok, err = validateKey(key)
if not ok then return nil, err end
ok, err = validateUserIds(userIds)
if not ok then return nil, err end
local needsExisting = userIds == nil or metadataTable == nil
local existingInfo
if needsExisting then
existingInfo = self:_getExistingInfo(key)
end
local finalUserIds = mergeDefaults(existingInfo and existingInfo:GetUserIds() or nil, userIds)
local finalMetadata = mergeDefaults(existingInfo and existingInfo:GetMetadata() or nil, metadataTable)
local incOptions = Instance.new("DataStoreIncrementOptions")
incOptions:SetMetadata(finalMetadata)
local requestType = Enum.DataStoreRequestType.StandardWrite
for attempt = 1, self._maxRetries do
if not self:_waitForBudget(requestType) then
self:_log("WARN", "Budget timeout on IncrementAsync for key %s", key)
return nil, "BudgetTimeout"
end
local success, result = pcall(function()
return self._store:IncrementAsync(key, delta or 1, finalUserIds, incOptions)
end)
if success then
return result
end
self:_log("ERROR", "IncrementAsync failed (attempt %d) key=%s err=%s", attempt, key, tostring(result))
if attempt < self._maxRetries and isTransientError(result) then
task.wait(self:_backoff(attempt))
else
return nil, result
end
end
return nil, "MaxRetriesExceeded"
end
function SafeDataStore:removeAsync(key)
local ok, err = validateKey(key)
if not ok then return false, err end
local requestType = Enum.DataStoreRequestType.StandardRemove
for attempt = 1, self._maxRetries do
if not self:_waitForBudget(requestType) then
self:_log("WARN", "Budget timeout on RemoveAsync for key %s", key)
return false, "BudgetTimeout"
end
local success, oldValue, oldInfo = pcall(function()
return self._store:RemoveAsync(key)
end)
if success then
return true, oldValue, oldInfo
end
self:_log("ERROR", "RemoveAsync failed (attempt %d) key=%s err=%s", attempt, key, tostring(oldValue))
if attempt < self._maxRetries and isTransientError(oldValue) then
task.wait(self:_backoff(attempt))
else
return false, oldValue
end
end
return false, "MaxRetriesExceeded"
end
function SafeDataStore:listKeysAsync(prefix, pageSize, cursor, excludeDeleted)
local requestType = Enum.DataStoreRequestType.StandardList
for attempt = 1, self._maxRetries do
if not self:_waitForBudget(requestType) then
self:_log("WARN", "Budget timeout on ListKeysAsync")
return nil, "BudgetTimeout"
end
local success, pages = pcall(function()
return self._store:ListKeysAsync(prefix, pageSize, cursor, excludeDeleted)
end)
if success then
return pages
end
self:_log("ERROR", "ListKeysAsync failed (attempt %d) err=%s", attempt, tostring(pages))
if attempt < self._maxRetries and isTransientError(pages) then
task.wait(self:_backoff(attempt))
else
return nil, pages
end
end
return nil, "MaxRetriesExceeded"
end
function SafeDataStore:listVersionsAsync(key, sortDirection, minDate, maxDate, pageSize)
local ok, err = validateKey(key)
if not ok then return nil, err end
local requestType = Enum.DataStoreRequestType.StandardList
for attempt = 1, self._maxRetries do
if not self:_waitForBudget(requestType) then
self:_log("WARN", "Budget timeout on ListVersionsAsync for key %s", key)
return nil, "BudgetTimeout"
end
local success, pages = pcall(function()
return self._store:ListVersionsAsync(key, sortDirection, minDate, maxDate, pageSize)
end)
if success then
return pages
end
self:_log("ERROR", "ListVersionsAsync failed (attempt %d) key=%s err=%s", attempt, key, tostring(pages))
if attempt < self._maxRetries and isTransientError(pages) then
task.wait(self:_backoff(attempt))
else
return nil, pages
end
end
return nil, "MaxRetriesExceeded"
end
-- Convenience: after any write that may have failed, force a fresh read to learn backend truth
function SafeDataStore:verifyAfterWrite(key)
return self:getAsync(key, false) -- UseCache = false
end
return SafeDataStore