Defines template, public paths and operations once. Exports the ready player facade to every server gameplay script.
DataStore library for Roblox
VirtualStore
A practical way to load player profiles, save changes, send safe data to the client, and recover work when a player disconnects or Roblox services fail.
- 01 One server owns a loaded profile
- 02 Important work survives disconnects
- 03 Clients receive only approved paths
Start here
Save player data with one shared module.
For most games, VirtualStore.players is the right entry point. It opens
Player_<UserId>, retries temporary load failures, releases the profile
when the player leaves, and can replicate selected fields to the client.
local ServerScriptService = game:GetService("ServerScriptService")
local VirtualStore = require(ServerScriptService.VirtualStore)
local Data = VirtualStore.players({
Name = "PlayerData",
Template = {
Wallet = { Coins = 0 },
Inventory = {},
},
Public = {
{"Wallet", "Coins"},
"Inventory",
},
})
Data:Add(player, {"Wallet", "Coins"}, 100)
Load the profile and make sure another server is not still using it.
Use helpers or a transaction so invalid data does not reach the live profile.
Send only the fields the player's UI needs.
Save one last time, then let the next server open the profile.
Quick guide
Which API should I use?
VirtualStore.playersUse this for the normal player join-to-leave lifecycle.
Set / Add / IncrementSimple, validated, and easy to replicate.
Edit / TransactionIf one check fails, none of the changes are kept.
Durable operationStore the pending work so another session can finish it.
CheckpointAsyncWait for a confirmed save before continuing.
Saga / coordinatorTrack every step because Roblox cannot save both keys at once.
Project structure
A project layout you can actually maintain.
The beginner layout is enough for a small game. The advanced layout is for projects where several systems or developers share the same profile and need clear ownership.
This is the simplest setup that stays safe. Server scripts call a few clear methods; clients read replicated values and ask the server to perform actions.
- Server owns every mutation and reward value.
- DataService is the only module that constructs VirtualStore.
- Gameplay scripts never write
session.Datadirectly. - Client scripts never receive the complete private profile.
Validates the RemoteEvent request, waits for loaded data and calls Add or Edit. It never decides based on client-provided reward values.
Connects to the named replica and observes public paths. It cannot save or mutate server data.
Projects the saved authoritative score into OrderedDataStore and serves bounded top/rank queries.
local VirtualStore = require(ServerScriptService.VirtualStore)
local Data = VirtualStore.players({
Name = "PlayerData",
Template = {
Wallet = { Coins = 0 },
Inventory = {},
},
Replication = {
Name = "PlayerData",
Paths = {{"Wallet", "Coins"}, "Inventory"},
},
})
return Data
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Data = require(ServerScriptService.DataService)
local claimRemote = ReplicatedStorage.Remotes.ClaimCoins
local lastClaim = {}
claimRemote.OnServerEvent:Connect(function(player)
local now = os.clock()
if now - (lastClaim[player] or 0) < 1 then return end
lastClaim[player] = now
local _, loadError = Data:Wait(player, 5)
if loadError then return end
local ok, err = Data:Add(
player,
{"Wallet", "Coins"},
10,
{Source = "CoinGiver"}
)
if not ok then warn(err.Code, err.Message) end
end)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Client = require(
ReplicatedStorage:WaitForChild("VirtualStoreClient")
)
local Replica = Client.connect("PlayerData")
Replica:Observe({"Wallet", "Coins"}, function(coins)
coinsLabel.Text = tostring(coins)
end)
Use this when economy, inventory, progression and operations are large enough to be maintained separately. Each module gets a clear job and fewer reasons to touch unrelated data.
- Bootstrap wires dependencies; it does not contain gameplay.
- Each domain owns a limited set of profile paths.
- Cross-server delivery enters through deterministic operations.
- Cross-key economy enters through a coordinator and journal.
WalletInventory grants, admin repairInventory / EquippedCurrency pricing, trade journalProgress / QuestsPurchase fulfillmentDeclared handler pathsYielding or external side effectsJournal + queued intentsDirect multi-key atomic claimsOrdered projection onlyAuthoritative economy decisionsAuthorized exact keyClient-controlled inputlocal ServerScriptService = game:GetService("ServerScriptService")
local VirtualStore = require(ServerScriptService.VirtualStore)
local Schema = require(script.Parent.Schema)
local Operations = require(script.Parent.Operations)
return VirtualStore.players({
Name = "PlayerData",
SchemaVersion = Schema.Version,
Template = Schema.Template,
Migrations = Schema.Migrations,
Operations = Operations,
Replication = require(script.Parent.Replication),
Settings = {
StrictData = true,
TrackChangeDetails = true,
AuditLogLimit = 50,
},
})
local EconomyService = {}
local Data
local VirtualStore
function EconomyService.Start(data, virtualStore)
Data = data
VirtualStore = virtualStore
end
function EconomyService.Purchase(player, itemId, price)
return Data:Edit(player, function(profile)
if profile.Wallet.Coins < price then
return VirtualStore.Cancel
end
profile.Wallet.Coins -= price
table.insert(profile.Inventory, itemId)
end, {
Reason = "Purchase",
Source = "EconomyService",
})
end
return EconomyService
return {
GrantMatchReward = function(profile, payload)
assert(type(payload.Coins) == "number")
profile.Wallet.Coins += payload.Coins
return {Granted = payload.Coins}
end,
}
local ServerScriptService = game:GetService("ServerScriptService")
local VirtualStore = require(ServerScriptService.VirtualStore)
local Data = require(script.Parent.PlayerData)
local Economy = require(ServerScriptService.Domains.EconomyService)
local Inventory = require(ServerScriptService.Domains.InventoryService)
Economy.Start(Data, VirtualStore)
Inventory.Start(Data, VirtualStore)
Data.Store.CriticalStateChanged:Connect(function(critical)
Economy.SetPurchasesEnabled(not critical)
end)
Create the player facade in one ModuleScript. Requiring that module returns the same configured data surface to every domain.
Remote handlers call domain services. They do not directly reach into arbitrary profile paths.
Handlers validate payload facts, mutate one profile and return a result. They never yield or call external services.
Repair capabilities live in server-only tooling and never cross a client RemoteEvent or RemoteFunction.
How it works
The parts worth knowing.
You do not need every feature on day one. Read the first few topics before starting, then come back when your game needs offline rewards, repair tools, or multi-key workflows.
01Player facade and core StoreChoose the entry point
VirtualStore.players is the convenient player lifecycle layer. VirtualStore.new is the lower-level key-based Store.
The player facade opens Player_<UserId>, associates GDPR IDs, tracks players, releases sessions and exposes short mutation helpers over a normal Store.
Use players for ordinary player profiles. Use the core Store for guilds, auctions, global records, custom keys or infrastructure code.
The facade is not a second persistence system. Advanced work remains available through Data.Store.
02Record envelope, template and schemaWhat a saved record contains
Every key stores gameplay Data beside internal Meta, a schema version and a VirtualStore format marker.
The template supplies missing defaults. Metadata holds the lease, revisions, user IDs, tags, write receipts and operation ledgers without mixing them into gameplay data.
Put persistent gameplay state in the template: currency, inventory, settings and progression. Treat metadata as framework-owned.
A template is not a fallback for a failed load. VirtualStore fails closed instead of replacing unknown stored data with defaults.
03Lease ownershipPrevent stale server saves
A lease is an expiring ownership token stored with one key. It identifies the only server allowed to save that active profile.
Open claims a token inside UpdateAsync. Every later write verifies the same stored token. Another server may claim only after release or expiry.
Leases automatically protect player joins, teleports, reconnects and any record that must have one active writer.
A lease is not a gameplay resource lock and should not be force-released casually; another server may still have unsaved RAM changes.
04Active SessionWork with live profile state
A Session is the server-memory view of one opened record, with safe mutation, save, operation and release methods.
It moves through explicit states such as Active, Releasing, Released and LeaseLost. Only an Active owner may mutate or commit.
Use the active session for gameplay involving an online player or an opened custom record.
Memory is not durable by itself. A full server-process crash can lose changes that were never checkpointed or represented as durable operations.
05StrictData and path mutationsMake normal changes observable
StrictData makes public profile data read-only. Changes go through Set, Patch, Increment, Insert, Remove or a transaction.
Safe helpers copy and validate the change, mark the session dirty, record changed paths and notify replication and audit listeners.
Use path helpers for small focused changes such as adding coins, changing a setting or inserting an item.
Direct writes to non-strict session.Data cannot immediately emit paths and can bypass validation, audit and replication behavior.
06Transaction and EditCommit several changes together
A transaction is an isolated working copy for several related changes inside one profile.
The callback edits the copy. Callback failure, cancellation or invalid data discards it; success validates and swaps the completed copy into live session memory.
Use it for shop purchases, crafting, equipping, reward claims and any action where all profile changes must succeed or none should.
A local transaction is not a DataStore write and cannot atomically commit several different keys.
07Named locks and lock transactionsCoordinate local systems
A named lock prevents two systems in the same server process from editing the same logical resource simultaneously.
WithLockTransaction holds the name, edits an isolated copy and commits only after the callback succeeds. Multi-session helpers acquire locks in deterministic key order.
Use it when inventory, crafting and trade code could race over one active session or several locally open participants.
Local locks do not protect another server and do not make later writes to separate DataStore keys atomic.
08Dirty state, autosave, checkpoint and releaseDecide when to save
Dirty state means gameplay data changed since the last confirmed commit. Autosave persists it later; checkpoint persists it now; release performs the final save and clears the lease.
Autosaves are staggered and concurrency-limited. Clean sessions skip unnecessary data writes until lease renewal is due. Release saves final data and unlocks in one write.
Rely on autosave for routine progress. Checkpoint before teleport or a risky external flow. Release when ownership must end.
A manual save is not needed after every mutation. Revision counts commits; DataRevision counts persisted gameplay-data changes.
09Write receiptsRecover ambiguous save responses
A write receipt is a bounded record of a unique commit token and whether that commit changed gameplay data.
If DataStore commits a write but the response is lost, retrying with the same token recognizes the completed commit instead of applying its transform twice.
VirtualStore uses this automatically for saves, releases and updates where a network failure can make the result uncertain.
Receipts are intentionally bounded. They prevent short-term retry ambiguity, not permanent business-request idempotency.
10Durable operationsFinish work after disconnect
A durable operation is uniquely identified pending work stored inside the target profile.
A deterministic non-yielding handler mutates profile data. The result receipt is committed with the same single-profile write, so retries can see whether it already applied.
Use it for developer products, crate outcomes, matchmaking rewards, offline grants and work that must survive disconnects, teleports or temporary outages.
Do not perform external side effects or random selection inside a retryable handler. Decide random outcomes before queueing and include them in the payload.
11Operation receipts, dedupe, TTL and dead lettersKeep failed work for repair
A receipt records terminal operation status. Dedupe tombstones remember completed IDs longer. TTL expires stale work. Dead letters retain bounded failed-operation details.
Attempts, timestamps and errors update during retries. The attempt limit or TTL produces a Failed receipt and dead letter. Reusing a retained ID returns its receipt instead of applying again.
Use status and dead letters to drive purchase retry, compensation, incident investigation and admin repair.
All ledgers are bounded. Never reuse operation IDs. Replay a failed operation under a new globally unique ID.
12Owner-only client replicationChoose client-visible fields
Replication is a read-only client mirror containing only server-approved public paths or transformed output.
The owner receives a revisioned initial snapshot, then path patches from safe mutations. A missed revision requests a fresh snapshot.
Use it for HUD currency, settings, level, XP, inventory views and quest progress that the owning client must display.
Publishing a parent can expose every child. Prefer narrow nested paths and never expose receipts, escrow, anti-cheat flags or admin data.
13Validation and schema migrationsUpgrade old profiles safely
Validation rejects values Roblox cannot safely store. Migrations convert an older known schema into the current schema one destination version at a time.
Open and offline update run required migrations, reconcile template defaults and validate before allowing the new record to commit.
Use migrations when renaming fields, changing inventory formats, splitting values or introducing a new persistent model.
Migration is not automatic corruption repair. Each migration must be deterministic and tested against real older shapes.
14DataStore and Memory adaptersTest without live DataStore
An adapter implements storage requests. The production adapter talks to Roblox DataStore; the memory adapter provides deterministic local storage for tests.
Core lifecycle and correctness code call the adapter contract instead of directly depending on live DataStore behavior.
Use the memory adapter for automated tests, retry simulation, lease conflicts, operation failure cases and scheduler benchmarks.
A memory benchmark does not measure Roblox network speed. Published-server comparisons need identical real conditions.
15Budgets, scheduler, diagnostics and critical stateWatch budgets and failures
Budget waiting controls request timing, the scheduler spreads autosaves, diagnostics count system behavior and critical state summarizes repeated recent issues.
Requests wait for their correct Roblox budget. Autosaves use capped concurrency. Metrics record requests, retries, waits, latency, failures, restores, deletes and lease loss.
Use diagnostics for dashboards, alerting, load testing and deciding whether trading or purchases should temporarily degrade during an incident.
Higher concurrency is not automatically faster. Measure diagnostics before changing safety-oriented defaults.
16MessagingService handoffA useful hint, not a guarantee
MessagingService sends hints that a waiting server wants a lease or that new operation work exists.
A message can encourage faster save, release or processing, while DataStore records, polling, autosave and lease timeout remain the correctness path.
Enable it to reduce reconnect, teleport handoff and offline-delivery latency across servers.
Messaging delivery is not guaranteed. Code must remain correct when every message is dropped.
17Version history, restore and deleteRecover or remove exact records
Version APIs inspect historical DataStore versions, restore one as a new latest version or create a deletion tombstone after exclusive ownership.
Restore and delete reject active foreign leases. Restore creates another version, preserving the state that existed before restoration.
Use it for administrator rollback, investigating lost progress and deliberate privacy/deletion workflows.
Restore is not safe while another server actively owns the profile, and Roblox controls how long old versions remain available.
18Authorized admin repairInspect and repair exact keys
Admin repair is a server-only, key-based set of inspection and recovery methods protected by an explicit AdminAuthorize callback.
Authorized tooling can inspect metadata, list pending work and dead letters, cancel or fail work, requeue with a new ID, compact ledgers or force-release an exact inspected lease token.
Use it from trusted incident tooling when normal retry and lease timeout are not enough.
Never expose a generic repair remote to clients. Dead-letter payloads and lease tokens contain sensitive operational data.
19Optional gameplay patternsReady-made helpers you can remove
Patterns are removable gameplay modules for cooldowns, daily rewards, purchase receipts, offline rewards and timed effects.
They prepare template fields and deterministic handlers, then call the same public transactions and durable operations as custom game code.
Use them to ship common systems quickly without designing their retry and idempotency behavior from scratch.
Patterns are not correctness-critical core. They can be deleted, and they never replace leases, persistence or operation receipts.
20OrderedIndex leaderboardsKeep a sortable copy
An optional OrderedDataStore extension that stores integer projections and reads sorted pages without using VirtualStore's profile envelope.
Writes use OrderedDataStore budgets and bounded retries. Top queries page through sorted results; rank lookup scans only up to a configured maximum.
Use it for persistent scoreboards, fastest times, season points and other sortable integer read models.
The projection is not atomic with the source profile and may lag. Never use it as the authoritative source for currency, purchases or trades.
21Trade saga, journal and escrowRecover multi-profile trades
A saga is a persistent multi-step workflow used because Roblox cannot atomically commit several DataStore keys. A journal records progress; escrow reserves assets before settlement.
The coordinator writes pending state, queues unique idempotent operations for each profile, observes receipts and retries, compensates or marks NeedsRepair when settlement cannot finish.
Use it for player trades, auctions, guild banks, ownership transfers and any valuable cross-profile economy action.
withLockTransactions only protects local memory. It is not a substitute for a persistent coordinator across DataStore keys.
Safety notes
What VirtualStore protects, and what it cannot.
Inside one DataStore key, VirtualStore can make useful guarantees. Across several keys or external services, Roblox does not give us a real transaction.
- One active lease owner per key inside
UpdateAsync. - Stale servers cannot overwrite a newer owner.
- Final save and lease release share one atomic write.
- Safe transactions roll back callback failures in memory.
- Durable operation data and receipts commit together.
- Write receipts recover ambiguous post-write responses.
- Client replicas are owner-only, revisioned and path-filtered.
- Admin repair fails closed without explicit authorization.
- Atomic commits across multiple DataStore keys.
- Permanent idempotency after bounded ledgers prune old IDs.
- Recovery of unsaved RAM-only changes after a process crash.
- Instant cross-server visibility or MessagingService delivery.
- Security of client-exposed admin capabilities or transforms.
- Rollback for direct mutations made outside strict mode.
- Automatic repair of every incomplete external workflow.
- Global scanning of every DataStore key.
Revision means commit count.
Use Metadata.DataRevision when only persisted gameplay-data changes matter.
When something fails
What the library does next.
Do not open blank data
The load is retried with the same token. If the stored profile cannot be read safely, the player is not allowed into gameplay.
Check whether it already saved
A recent write receipt lets a retry recognize a commit whose response was lost.
Leave the reward pending
Durable work remains in the profile so a later session can finish it.
Stop retrying and report it
After the retry limit, the operation becomes Failed and its details are kept for repair.
Data:DeliverAsync(userId, `match:${matchId}:reward`, "GrantMatchReward", {
Coins = 250,
})
Client data
Send the UI only what it needs.
Mark exact paths as public. If you publish {"Wallet", "Coins"}, the client
does not receive reserved currency, trade escrow, or other fields beside it.
Replication = {
Paths = {
{"Wallet", "Coins"},
{"Progress", "Level"},
{"Progress", "XP"},
},
}
Data:Observe({"Wallet", "Coins"}, function(coins)
coinsLabel.Text = coins
end)
Leaderboards
Keep the real score in the profile.
OrderedIndex copies a saved integer into OrderedDataStore so you can sort it. If that copy is late or fails, the player's real score is still safe in VirtualStore.
This is the value your game trusts when granting rewards or checking purchases.
Data.Wallet.Coins = 1250
This copy exists only so the game can show top lists and look up nearby ranks.
"820815974" = 1250
Gameplay uses Data:Add or a transaction. OrderedDataStore is not touched yet.
Session save succeeds, proving the authoritative profile value reached DataStore.
BindSession writes the selected path using OrderedDataStore budgets and bounded retries.
GetTopAsync returns ranked entries; GetRankAsync scans only within its configured bound.
local Coins = VirtualStore.OrderedIndex.new({
Name = "CoinsLeaderboard",
Ascending = false,
Settings = { CacheTTL = 15 },
})
Data.Loaded:Connect(function(player, _profile, session)
Coins:BindSession(
session,
tostring(player.UserId),
{"Wallet", "Coins"},
{ProjectNow = true}
)
end)
local top, topError = Coins:GetTopAsync(100, {
MinValue = 1,
PageSize = 50,
})
local rank, rankError = Coins:GetRankAsync(
tostring(player.UserId),
{MaxScan = 1000}
)
if rankError and rankError.Code == "RankScanLimit" then
print("Player is outside the scanned leaderboard range")
end
Do not add Coins in two places.
Change profile Coins in VirtualStore, then copy the final value with SetAsync, ProjectAsync or BindSession. IncrementAsync is for a separate counter owned by OrderedDataStore. It is not retried automatically because a lost response could otherwise add the value twice.
Optional helpers
Common systems you can keep or remove.
These helpers use the public API. Delete them when your game does not need them; the core library keeps working.
Cooldowns
Saved timestamps and isolated session transactions for local gameplay gates.
Data.CooldownsDaily rewards
UTC day numbers, deterministic IDs and server-selected rewards.
Data.DailyPurchase receipts
ProcessReceipt grants only after a durable operation reaches Applied.
Data.PurchasesOffline rewards
Server-calculated duration and idempotent reward delivery.
Data.OfflineTimed effects
Refresh, extend, stack, ignore and replace with durable queue helpers.
Data.EffectsTrading
A safe trade needs a recovery plan.
Roblox cannot save two player profiles in one atomic write. For valuable trades, keep a journal, reserve the offered assets, and make every settlement step safe to retry.
Both offers are server validated. Changing an offer clears acceptance.
Each profile moves offered coins into durable escrow.
Idempotent operations release escrow and grant incoming coins.
Incomplete workflows remain visible as pending or NeedsRepair.
Inside the package
Where the main responsibilities live.
Factories, constants and dependency wiring. No Store method implementations.
Examples
How the pieces work together.
These are the situations that usually cause data bugs. Each walkthrough shows the normal path and what happens when a request, server, or player disappears.
Player joins, earns coins and leaves
players + Add + autosave + releaseVirtualStore.players receives PlayerAdded and repeatedly attempts to claim Player_<UserId> with one stable lease token.
After migration, reconciliation and validation, state becomes Ready. Gameplay may now read or mutate the profile.
Data:Add(player, "Coins", 100) validates the new number, marks dirty and emits the Coins path for replication.
Autosave commits dirty data later. On leave, release saves final data and clears the lease in the same single-key write.
Player purchases an inventory item
Edit / TransactionServer code selects the item and trusted price. The client only requests an action; it does not author currency or reward values.
The transaction checks balance, subtracts coins and inserts the item into an isolated working copy.
If any check fails, return VirtualStore.Cancel. Nothing changes. If validation succeeds, all profile changes enter live memory together.
The owner client receives the changed public currency and inventory paths, while private purchase metadata remains server-only.
Crate result must survive disconnect
DeliverAndWaitAsync + receiptThe server selects the random reward before queueing. The stable outcome enters the operation payload.
A globally unique request ID creates pending work inside the profile. Reusing a retained ID returns the existing status.
The deterministic handler consumes the key and grants the selected reward. Data and the Applied receipt commit together.
Only show the final opening after the Applied receipt. A disconnect leaves pending work available for later processing.
DataStore fails while the player is entering
stable-token retry + fail closedA failed request never causes VirtualStore to open the template as if no stored record existed.
Load attempts reuse the same token, allowing an ambiguous successful lease claim to be recognized safely.
The player facade moves through Loading and Retrying. Game code waits for Ready instead of guessing.
After the configured attempts, loading becomes Failed and the default player facade kicks with a clear retry message.
Two players trade valuable assets
locks + journal + escrow + sagaServer-authoritative offers and deterministic local locks prevent concurrent same-server edits while both players accept.
A persistent trade ID records participants, offers and workflow status before settlement begins.
Unique per-profile durable operations move assets to escrow, then release outgoing value and grant incoming value.
The coordinator observes receipts, retries missing steps and marks permanent failures for compensation or NeedsRepair.
API reference
Public methods, with context.
VirtualStore.players.Keep one shared DataService, wait until the player is ready, then use Add, Set or Edit. Autosave and release handle the routine saves.
Session methods are for loaded profiles. Store methods are for exact offline keys. Durable operations are for work that must resume later.
Storage and mutation calls usually return valueOrSuccess, virtualError. Stop the action and log the error code rather than assuming it worked.
How to use itNormal server gameplay
local ServerScriptService = game:GetService("ServerScriptService")
local Data = require(ServerScriptService.DataService)
local _, loadError = Data:Wait(player, 5)
if loadError then
warn(loadError.Code, loadError.Message)
return
end
local ok, mutationError = Data:Add(
player, {"Wallet", "Coins"}, 100,
{Reason = "QuestReward", Source = "QuestService"}
)
if not ok then warn(mutationError.Code) end
Beginner: this is the default surface for player data. Do not manually open another Store for the same player key.
Advanced: wrap this facade inside domain services so RemoteEvents cannot call arbitrary paths or choose reward amounts.
Wait / AwaitWaits until a player's load state reaches Ready or fails. Use before gameplay reads profile data.
IsLoaded / GetSessionChecks readiness or returns the active underlying Session for advanced server code.
Get / GetPathReads the complete profile or one string/nested-array path without exposing mutation.
Add / IncrementAdds a number at one path. Use for currency, XP and numeric counters.
Set / PatchReplaces one path or computes its next value with a callback.
Insert / RemoveSafely changes an array-like path such as inventory or unlocked rewards.
Edit / UpdateRuns a one-profile transaction for several related changes and complete rollback on failure.
WithLock / WithLockTransactionCoordinates a named local resource; the transactional form also rolls back callback failures.
DeliverAsyncQueues one durable operation for an online player or offline UserId and returns without waiting for completion.
DeliverAndWaitAsync / GetDeliveryAsyncWaits for or checks the operation receipt when gameplay must know the terminal result.
CheckpointAsync / SaveAsyncPersists current memory while keeping the player session active. Checkpoint communicates a deliberate durability boundary.
ReplicateNowForces a fresh public snapshot when custom server code needs immediate replica refresh.
ReleaseAsync / CloseAsyncReleases one player or closes the entire facade and its underlying Store.
How to use itExact offline key update
local snapshot, updateError = Data.Store:UpdateAsync(
"Player_123",
function(profile)
profile.Wallet.Coins += 250
end
)
if updateError then
warn(updateError.Code, updateError.Message)
end
Use Store methods when: you know the exact key and no local active Session should own the edit.
Critical rule: offline update transforms may run multiple times and must not yield or perform external side effects.
OpenAsyncClaims a lease and creates one active Session. Use when this server needs ongoing ownership and writes.
ViewAsyncReads a stored snapshot without claiming an active session. Use for read-only server tools and inactive records.
UpdateAsyncAtomically edits an offline record with no active lease. Its non-yielding transform may run more than once.
QueueOperationAsyncWrites uniquely identified durable work into a target profile, even while the owner is offline.
GetOperationAsync / AwaitOperationAsyncReads or waits for operation status without exposing dead-letter payloads.
ListVersionsAsync / ViewVersionAsync / ViewAtTimeAsyncInspects historical Roblox DataStore versions without changing the current record.
RestoreVersionAsyncRestores a historical snapshot as a new latest version after confirming no active lease blocks it.
GetDeleteConfirmation / DeleteAsyncRequires explicit exact confirmation, claims exclusive ownership and creates a deletion tombstone.
GetDiagnostics / IsCriticalReturns request metrics and whether recent issue thresholds place the Store in critical state.
GetSession / IsOpen / CloseAsyncInspects local ownership or performs bounded shutdown release for all active sessions.
How to use itValidated multi-field purchase
local session = Data:GetSession(player)
if not session then return end
local ok, purchaseError = session:Transaction(
function(profile)
if profile.Wallet.Coins < 500 then
return VirtualStore.Cancel
end
profile.Wallet.Coins -= 500
table.insert(profile.Inventory, "Sword")
end,
{Reason = "Purchase", Source = "ShopService"}
)
Beginner: prefer facade methods unless you need Session-specific signals, locks, tags or manual operation processing.
Advanced: a Session is live memory under one lease. It is the correct surface for active-player domain services.
IsActive / GetStateChecks whether this Session still owns the lease and may safely mutate.
GetData / Get / SnapshotReads current memory or produces a copied snapshot for inspection.
GetMetadata / GetTag / SetTagReads public metadata or manages small record tags separate from gameplay data.
AddUserId / RemoveUserIdAssociates Roblox user IDs with the record for GDPR-aware persistence.
ReconcileAdds missing template defaults through the normal safe-mutation path.
TransactionEdits a copied profile and commits only when callback and validation both succeed.
Set / Patch / Increment / Insert / RemovePerforms focused validated mutations and emits exact changed paths.
IsDirty / MarkDirty / MarkCleanInspects or explicitly controls whether gameplay data requires another persistence commit.
GetDiff / GetAuditLogReads optional change details and recent audit entries when their settings are enabled.
WithLock / WithLockTransaction / IsLockedCoordinates local logical resources; use the transactional form for rollback.
QueueOperationAsync / ProcessOperationsAsyncQueues work for this profile or explicitly processes pending operation handlers.
SaveAsync / CheckpointAsync / ReleaseAsyncPersists memory, marks a known durability boundary or ends lease ownership.
How to use itInspect then repair one exact key
local ADMIN_CAPABILITY = {}
local Store = VirtualStore.new({
Name = "PlayerData",
Template = {},
AdminAuthorize = function(_action, _key, context)
return context == ADMIN_CAPABILITY
end,
})
local deadLetters = Store:ListDeadLettersAsync(
"Player_123", ADMIN_CAPABILITY
)
Safe order: inspect, understand, prepare a repair plan, then perform the smallest exact-key action.
Never: send the capability, lease token, dead-letter payload or generic repair method through a client remote.
InspectMetadataAsyncReads internal metadata, including the exact current lease token, after authorization.
ListPendingOperationsAsyncLists unfinished durable work for one exact key.
ListDeadLettersAsyncReturns retained failure details and payloads allowed by the configured byte limit.
ClearPendingOperationAsyncCreates a terminal Cancelled receipt and dedupe tombstone instead of silently forgetting the ID.
ForceFailOperationAsyncTurns pending work into a Failed receipt and dead letter for deliberate investigation.
RequeueDeadLetterAsyncQueues the retained payload under a new globally unique ID while preserving the original failure.
ForceReleaseLeaseAsyncReleases only the exact inspected token; changed or guessed tokens are rejected.
CompactLedgersAsyncNormalizes and prunes bounded ledgers, but refuses to run while an active lease may need its receipts.
How to use itSaved profile score leaderboard
local Leaderboard = VirtualStore.OrderedIndex.new({
Name = "SeasonPoints",
Ascending = false,
Settings = {
CacheTTL = 15,
MaxRankScan = 1000,
},
})
Leaderboard:BindSession(
session,
tostring(player.UserId),
{"Progress", "SeasonPoints"}
)
Projection rule: the source profile remains authoritative. A leaderboard write can fail or temporarily lag without changing the player's real value.
Rank rule: OrderedDataStore has no direct rank lookup. GetRankAsync scans sorted pages and stops at MaxScan.
new(config)Creates an integer-only OrderedDataStore projection with sort direction, optional value range, retry, budget, cache and scan settings.
SetAsync / ProjectAsyncReplaces one projected integer value. Prefer this for profile-backed scores.
IncrementAsyncAtomically increments an independent OrderedDataStore counter. It makes one attempt and returns AmbiguousIncrement on failure because automatic retry could apply the increment twice.
GetAsync / RemoveAsyncReads or removes one exact projected key.
GetTopAsyncReads ranked sorted entries with optional direction, page size, minimum, maximum and cache bypass.
GetRankAsyncScans sorted pages up to MaxScan; returns RankScanLimit when the key lies outside the bounded scan.
ProjectSessionAsyncReads one integer path from a Session and immediately writes its projection.
BindSessionProjects the selected Session path after successful saves; optional ProjectNow writes the loaded value once.
ClearCache / GetDiagnosticsInvalidates local sorted-query cache or reads request, retry, budget, cache and query metrics.
OrderedIndex.MemoryAdapterProvides deterministic integer storage, sorted pages, failure injection and budget control for tests.
How to use itRead-only nested path observer
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Client = require(
ReplicatedStorage:WaitForChild("VirtualStoreClient")
)
local Replica = Client.connect("PlayerData")
Replica:Await()
Replica:Observe({"Wallet", "Coins"}, function(coins)
coinsLabel.Text = tostring(coins)
end)
Client responsibility: display public data and send action requests. It never decides authoritative values or writes the replica.
Server responsibility: expose exact safe paths and validate every RemoteEvent request independently.
connect(name)Connects to one named owner-only replica channel installed by the server.
Await / GetStateWaits for the initial snapshot or inspects whether replica data is ready.
Get / GetPathReads public replicated data only. The returned replica is read-only.
Observe(path, callback)Runs a callback when one top-level or nested public path changes.
Changed / StateChangedSignals general replica patches and connection-state transitions.
DisconnectStops observers and releases local replica resources during custom teardown.
How to use itOperational monitoring and changed paths
Data.Store.CriticalStateChanged:Connect(function(critical, diagnostics)
PurchaseService.SetEnabled(not critical)
warn("Recent issues", diagnostics.RecentIssueCount)
end)
session.OnChanged:Connect(function(change)
print(change.Reason, change.Source, change.Paths)
end)
Use signals for: monitoring, presentation updates, diagnostics and coordinating services after confirmed lifecycle events.
Do not use signals as: a replacement for persisted receipts or a guaranteed cross-server event bus.
store.OnErrorReceives structured Store errors for logging and operational monitoring.
CriticalStateChangedFires when recent issue thresholds enter or recover from critical state.
SessionOpened / SessionClosedReports local active-session lifecycle changes.
OperationQueued / Applied / Retried / FailedReports durable-work progress for diagnostics and workflow coordination.
session.OnChangedReceives reason, source, timestamp, revision and always-included changed paths.
session.OnSaved / OnReleased / HandoffRequestedReports confirmed persistence, ownership end and cross-server handoff hints.
Settings
Change these only for a reason.
The defaults suit a normal game. Before changing one, check which request budget,
timeout, or record-size tradeoff it controls. Keep LeaseTimeout greater than twice AutoSaveInterval.
AutoSaveInterval = 60Target seconds between routine session saves. Increase to reduce writes; decrease only after measuring budget pressure.
AutoSaveCheckInterval = 1How often the scheduler checks for due sessions. It does not mean every session writes each second.
AutoSaveConcurrency = 2Maximum simultaneous autosaves. Protects budgets during join spikes and large servers.
AutoSaveRetryInterval = 10How quickly failed autosaves become due again.
LeaseTimeout = 180How long ownership survives without renewal. Must exceed two autosave intervals.
OpenTimeout = 20Maximum time an open waits for an active lease or storage progress before returning an error.
RetryAttempts = 5General bounded adapter request attempts before the operation returns failure.
BudgetTimeout = 10Maximum wait for the correct Roblox request budget.
BudgetPollInterval = 0.25Budget availability check frequency while waiting.
CloseTimeout = 25Total bounded shutdown window for retrying final releases.
CloseRetryInterval = 1Delay between failed shutdown release attempts.
CloseConcurrency = 10Maximum simultaneous shutdown releases.
OperationProcessLimit = 50Maximum pending operations processed in one pass so one profile cannot monopolize a write.
MaxOperationAttempts = 10Retry ceiling before pending work becomes a terminal Failed receipt.
OperationTTL = 0Maximum pending age in seconds. Zero disables expiry.
UnknownOperationPolicy = "Retry"Controls whether missing handlers retry, cancel or immediately fail.
OperationReceiptLimit = 500Retained full result/error receipts. Larger values improve inspection but consume record bytes.
OperationDedupeLimit = 5000Retained minimal completed-ID tombstones after full receipt pruning.
DeadLetterLimit = 100Retained terminal failure details. Use lower values when operation payloads are large.
DeadLetterPayloadMaxBytes = 8192Maximum payload bytes copied into one dead letter; larger payloads are omitted but measured.
WriteReceiptLimit = 20Recent commit-token receipts retained for ambiguous save-response recovery.
MaxDataBytes = 3800000Rejects records before approaching Roblox's storage limit.
StrictData = falseMakes public Session data read-only when enabled. The player facade enables it by default.
TrackChangeDetails = falseIncludes old/new detail in change events and diffs at additional memory cost.
AuditLogLimit = 0Retains recent local audit entries. Zero disables the audit buffer.
CriticalErrorThreshold = 5Recent issue count required to enter critical state.
CriticalWindow = 120Seconds used when counting recent issues.
CriticalRecovery = 60Healthy time required before leaving critical state.
EnableMessaging = trueEnables optional handoff and operation notifications. It is never the correctness mechanism.
AutoReleaseOnHandoff = trueAllows a current owner to release sooner after receiving a waiting-server hint.
RetryAttempts = 5Bounded OrderedDataStore request attempts before returning an explicit error.
RetryDelay = 0.5 / RetryBackoff = 2Delay and exponential multiplier between failed projection or query attempts.
BudgetTimeout = 10Maximum wait for OrderedRead, OrderedWrite, OrderedRemove or GetSortedAsync request budget.
DefaultPageSize = 50Sorted page size used when the query does not specify one; Roblox permits at most 100.
MaxQueryEntries = 1000Hard ceiling for one top query or rank scan request.
MaxRankScan = 1000Default number of sorted entries scanned while searching for one key's rank.
CacheTTL = 0Local sorted-query cache duration. Zero disables cache; writes clear this server's cached lists.
Errors and states
What to do when a call does not succeed.
VirtualStore returns an error instead of quietly opening blank data. Check the error code, log the detail, and stop the current gameplay action when the profile is not safe to use.
LoadingInitial open has started; gameplay must wait.
RetryingA temporary load attempt failed and the same claim is retrying.
ReadyValidated active Session exists and gameplay may proceed.
FailedConfigured attempts ended without a safe profile; fail closed.
Releasing / ReleasedFinal ownership end is in progress or confirmed.
Queued / PendingWork is stored but has not reached a terminal receipt.
AppliedHandler mutation and success receipt committed together.
CancelledWork deliberately ended without applying its intended mutation.
FailedWork ended after policy, attempts, expiry or administrator action.
UnknownNo retained pending, receipt or dedupe record currently identifies the ID.
LockTimeoutAnother active lease did not release in time. Wait, retry later or investigate before exact-token repair.
LeaseLostThis server no longer owns the stored token. Stop writes and end gameplay tied to this Session.
PlayerDataUnavailablePlayer facade has no Ready Session. Do not grant or mutate; wait for Ready or stop the action.
AdminAccessDisabled / DeniedAdmin authorization is absent or rejected. Never work around it through a client remote.
LeaseTokenMismatchThe inspected lease changed before force release. Reinspect; never release a guessed token.
- Enable strict player profiles and perform mutations through helpers or transactions.
- Use globally unique operation, receipt, purchase and trade IDs that are never recycled.
- Keep random selection and external side effects outside retryable operation handlers.
- Replicate exact public paths; audit every parent path and transform for private data leaks.
- Use checkpoints or durable operations before presenting critical external flows as final.
- Build persistent saga/journal recovery for trades, auctions and other multi-key workflows.
- Keep admin capabilities server-only and monitor dead letters, critical state and diagnostics.
- Test outages, lease conflict, duplicate retries, shutdown, migration and missing handlers before release.
- Keep leaderboard projections separate from authoritative currency and expect temporary OrderedIndex lag.
Testing
Test the failure cases before publishing.
The included benchmark opens 100 memory-backed sessions and adds fake write latency. It is useful for checking scheduler behavior, but it is not a measurement of Roblox's live DataStore speed.
VirtualStore v0.1.0
Start with the simple API.
Use the player facade for normal data. Add durable operations, repair tools, and trade recovery only when the game actually needs them.