DataStore Pro Kit
A battle-tested data layer for Roblox experiences.
Overview
DataStore Pro Kit is a production-quality DataStore wrapper that sits
between your game code and Roblox's DataStoreService. It eliminates the
boilerplate and foot-guns that come with raw DataStore calls and gives you a reliable,
high-performance data layer out of the box.
Problems it solves:
- Data loss on errors — automatic exponential-backoff retries with jitter so transient failures don't lose player progress.
- Rate-limit throttling — budget-aware request scheduling that waits for available credits instead of eating retries on 429s.
- Duplicate writes during teleports — optional MemoryStore-backed session locking prevents two servers from writing the same key at once.
- Schema evolution — sequential migration functions upgrade old data formats automatically when a player's data is loaded.
- Redundant reads — in-memory LRU cache with TTL expiration and dirty-entry tracking cuts DataStore reads dramatically.
- Race conditions — per-key concurrency locks serialise overlapping
Get/Set/Updatecalls for the same player. - Shutdown data loss — parallel
SaveAllflushes every dirty entry during the ~30 sBindToClosewindow.
The kit ships four modules — DataStoreManager (core), SessionLock, Migration, and Cache — that work together or independently.
Installation
- Download the
.rbxmmodel file from your Baseplate purchase page. - In Roblox Studio, right-click ServerStorage → Insert from File… and select the
.rbxm. -
You should see a folder structure like:
ServerStorage └─ DataStoreProKit ├─ DataStoreManager (ModuleScript) ├─ SessionLock (ModuleScript) ├─ Migration (ModuleScript) └─ Cache (ModuleScript) -
Require the modules in your server scripts:
local DataStoreManager = require(game.ServerStorage.DataStoreProKit.DataStoreManager) local SessionLock = require(game.ServerStorage.DataStoreProKit.SessionLock) -- Migration and Cache are used internally by DataStoreManager, -- but you can require them directly for advanced use cases.
Important: The kit must live in ServerStorage or
ServerScriptService — never in ReplicatedStorage, because
DataStoreService is server-only.
Quick Start
Drop this into a Script in ServerScriptService and you
have working data persistence in under 60 lines:
local Players = game:GetService("Players")
local DataStoreManager = require(game.ServerStorage.DataStoreProKit.DataStoreManager)
local SessionLock = require(game.ServerStorage.DataStoreProKit.SessionLock)
-- 1. Create the manager
local manager = DataStoreManager.new({
name = "PlayerData_v1",
retryAttempts = 5,
retryDelay = 1,
cacheTTL = 60,
schemaVersion = 1,
defaultData = {
coins = 0,
level = 1,
inventory = {},
settings = { musicOn = true },
},
})
-- 2. Create a session lock (prevents dual-server writes during teleports)
local sessionLock = SessionLock.new({
dataStoreName = "PlayerData_v1",
})
-- 3. Table to hold loaded data per player
local playerData: { [number]: { [string]: any } } = {}
-- 4. Load on join
Players.PlayerAdded:Connect(function(player: Player)
local acquired = sessionLock:Acquire(player.UserId)
if not acquired then
warn("Could not acquire session lock for", player.Name)
player:Kick("Data is still saving on another server. Please rejoin.")
return
end
local success, data = manager:Get(player.UserId)
if success then
playerData[player.UserId] = data
else
warn("Failed to load data for", player.Name, ":", data)
player:Kick("Failed to load your data. Please rejoin.")
sessionLock:Release(player.UserId)
end
end)
-- 5. Save on leave
Players.PlayerRemoving:Connect(function(player: Player)
local data = playerData[player.UserId]
if data then
manager:Set(player.UserId, data)
end
playerData[player.UserId] = nil
sessionLock:Release(player.UserId)
end)
-- 6. Flush everything on shutdown
game:BindToClose(function()
manager:SaveAll()
sessionLock:ReleaseAll()
end)
API Reference
DataStoreManager
The core module. Handles reading, writing, caching, retries, and migrations.
DataStoreManager.new(config)
Creates a new manager instance. Config fields:
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | DataStore name passed to DataStoreService:GetDataStore(). |
retryAttempts | number? | 5 | Maximum retry attempts per DataStore call. |
retryDelay | number? | 1 | Base delay in seconds for exponential backoff. |
cacheTTL | number? | 60 | Seconds before a cached entry expires. 0 = infinite. |
schemaVersion | number? | 1 | Current schema version. Triggers migrations on older data. |
migrations | { [number]: MigrationFn }? | {} | Map of version → transform function. Each receives and returns a data table. |
defaultData | { [string]: any }? | {} | Template data applied to brand-new players. |
Core Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
:Get(playerId) |
playerId: string | number |
(boolean, data | errorString) |
Returns cached data if within TTL, otherwise fetches from DataStore. Auto-migrates stale schemas and applies defaultData for new players. Data is deep-copied so mutations don't affect the cache. |
:Set(playerId, data) |
playerId: string | number, data: table |
(boolean, errorString?) |
Overwrites the player's data. Updates the cache immediately and attempts a write-through to DataStore. On failure, the entry is left dirty for SaveAll to retry. |
:Update(playerId, transformFn) |
playerId: string | number, transformFn: (data) → data |
(boolean, updatedData | errorString) |
Atomic update via DataStore:UpdateAsync. The transform function receives current data (migrated/defaulted as needed) and must return the new data table. |
:Remove(playerId) |
playerId: string | number |
(boolean, errorString?) |
Deletes the player's data from both the cache and DataStore. |
:SaveAll() |
none | (boolean, { errorStrings }?) |
Flushes every dirty cache entry to DataStore in parallel via task.spawn. Critical for game:BindToClose where you have ~30 s. |
:Destroy() |
none | void | Calls SaveAll then clears all internal caches and locks. Call when you no longer need the manager. |
Utility Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
:GetBudget() |
none | { get: number, set: number, update: number, list: number } |
Returns the current DataStore request budget for each operation type. |
:GetMetrics() |
none | Metrics |
Returns a snapshot of internal counters: gets, sets, updates, removes, cacheHits, cacheMisses, retries, errors, migrations. |
:ClearCache(playerId?) |
playerId: (string | number)? |
void | Evicts one player's cache entry, or the entire cache if no argument is given. Dirty entries are discarded — call SaveAll first if needed. |
:IsDirty(playerId?) |
playerId: (string | number)? |
boolean |
Returns true if the specified player has unsaved data. With no argument, returns true if any dirty entry exists. |
SessionLock
Distributed lock backed by MemoryStoreService to prevent concurrent server writes.
SessionLock.new(config)
| Field | Type | Default | Description |
|---|---|---|---|
dataStoreName | string | required | Must match the DataStoreManager's name so locks correspond to the correct store. |
lockTTL | number? | 30 | Seconds before an un-refreshed lock auto-expires (crash recovery). |
heartbeatInterval | number? | 10 | Seconds between heartbeat refreshes that keep the lock alive. |
acquireTimeout | number? | 15 | Max seconds to wait for another server to release the lock. |
acquireRetryDelay | number? | 1 | Seconds between acquire retry attempts. |
SessionLock Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
:Acquire(playerId) |
playerId: number |
boolean |
Yields up to acquireTimeout seconds trying to claim the lock. Returns true on success. Automatically starts a heartbeat thread to keep the lock alive. Overwrites stale locks whose heartbeat is older than lockTTL. |
:Release(playerId) |
playerId: number |
void | Stops the heartbeat and removes the lock from MemoryStore. |
:ReleaseAll() |
none | void | Releases every lock held by this server. Call from game:BindToClose. |
:IsHeld(playerId) |
playerId: number |
boolean |
Returns true if this server currently holds the lock for the player. |
Migration
Standalone schema migration module. Used internally by DataStoreManager but can be used directly for advanced scenarios.
Migration.new(config)
| Field | Type | Default | Description |
|---|---|---|---|
currentVersion | number | required | Target schema version (must be ≥ 1). |
migrations | { [number]: MigrationFn } | required | Map of version → (data) → data transform functions. |
Migration Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
:GetCurrentVersion() |
none | number |
Returns the target schema version. |
:StampVersion(data) |
data: table |
table |
Sets _schemaVersion on the data without running migrations. Use for brand-new profiles. |
:Migrate(data) |
data: table |
(table, boolean) |
Runs migration functions sequentially from the data's version to currentVersion. Returns the (possibly transformed) data and a boolean indicating whether any migrations ran. Pre-validates that all required functions exist before touching the data. |
Cache
In-memory LRU cache with TTL expiration and dirty-entry tracking. Used internally by DataStoreManager but available for standalone use.
Cache.new(config?)
| Field | Type | Default | Description |
|---|---|---|---|
ttl | number? | 60 | Seconds before an entry expires. |
maxEntries | number? | 500 | Maximum entries before LRU eviction kicks in. |
Cache Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
:Set(key, data) |
key: string, data: any |
void | Stores or overwrites an entry. Resets TTL timer and clears the dirty flag. Triggers LRU eviction if at capacity. |
:Get(key) |
key: string |
any? |
Retrieves an entry. Returns nil if missing or expired (lazily removed on access). Promotes the entry to MRU on hit. |
:MarkDirty(key) |
key: string |
void | Marks an entry as having unsaved changes. Dirty entries are protected from LRU eviction. |
:GetDirty() |
none | { [string]: any } |
Returns all dirty entries as a key → data map for batch saving. |
:ClearDirty(key) |
key: string |
void | Clears the dirty flag for an entry (call after a successful save). |
:Remove(key) |
key: string |
void | Removes a single entry from the cache. |
:Clear() |
none | void | Clears the entire cache and resets all counters. |
:GetStats() |
none | { entries: number, dirtyCount: number, hitRate: number } |
Returns cache statistics including hit rate (0–1). |
Session Locking
The Problem
When a player teleports between servers (or reconnects quickly), the departing server
may still be executing its PlayerRemoving save while the new server
loads the same key. This creates a race condition:
- Server A starts saving Player_123's data.
- Server B loads Player_123's data (gets the old copy).
- Server A's save completes — writes newer data.
- Server B eventually saves — overwrites Server A's newer data with the stale copy.
How Baseplate Solves It
The SessionLock module uses MemoryStoreService (a
MemoryStore SortedMap) to create a distributed lock per player key. The flow:
- Acquire — Before loading data, call
sessionLock:Acquire(playerId). It writes a lock entry containing the current server'sJobIdand a heartbeat timestamp. - Heartbeat — A background thread refreshes the heartbeat every
heartbeatIntervalseconds (default 10) so the lock stays alive. - Contention — If another server holds the lock,
Acquireyields and retries up toacquireTimeoutseconds (default 15). If the existing lock's heartbeat is older thanlockTTL(default 30 s), it's considered stale and overwritten. - Release — After saving, call
sessionLock:Release(playerId)to remove the lock and stop the heartbeat. - Crash recovery — If a server crashes without releasing, the lock auto-expires after
lockTTLseconds because the heartbeat stops.
Example
local sessionLock = SessionLock.new({
dataStoreName = "PlayerData_v1",
lockTTL = 30, -- lock expires after 30s without heartbeat
heartbeatInterval = 10, -- refresh every 10s
acquireTimeout = 15, -- wait up to 15s for another server
acquireRetryDelay = 1, -- poll every 1s while waiting
})
Players.PlayerAdded:Connect(function(player: Player)
local acquired = sessionLock:Acquire(player.UserId)
if not acquired then
player:Kick("Your data is still saving on another server. Please rejoin in a moment.")
return
end
-- Safe to load and use data now
end)
Players.PlayerRemoving:Connect(function(player: Player)
-- Save data first, THEN release the lock
manager:Set(player.UserId, playerData[player.UserId])
sessionLock:Release(player.UserId)
end)
game:BindToClose(function()
manager:SaveAll()
sessionLock:ReleaseAll()
end)
When to Disable Session Locking
Session locking adds a MemoryStore round-trip on every join. You can skip it when:
- Your game is single-server (no teleports, no matchmaking).
- You use
DataStoreManager:Update()exclusively —UpdateAsyncis already atomic. - You're in Studio testing and MemoryStore isn't available.
Schema Migrations
How It Works
Every data table stored by the kit contains a hidden _schemaVersion field.
When DataStoreManager:Get() or :Update() loads data whose
_schemaVersion is lower than the manager's configured schemaVersion,
migration functions run sequentially from
_schemaVersion + 1 through schemaVersion.
After migration, the kit immediately persists the upgraded data back to the DataStore.
If that save fails, the entry is marked dirty so SaveAll can retry later.
Safety Guarantees
- The standalone
Migrationmodule pre-validates that every required migration function exists before touching the data, so data is never left in a partially-migrated state. - If a migration function doesn't return a table, the migration is aborted and the original data is returned unchanged.
- If the data's version is ahead of the current version (e.g., after a rollback), it's returned unchanged with a warning.
Three-Version Migration Example
local manager = DataStoreManager.new({
name = "PlayerData",
schemaVersion = 3,
migrations = {
-- v0 → v1: Add a "coins" field (new players who predate the economy update)
[1] = function(data)
data.coins = data.coins or 0
return data
end,
-- v1 → v2: Rename "xp" to "experience" for clarity
[2] = function(data)
data.experience = data.xp or 0
data.xp = nil
return data
end,
-- v2 → v3: Add nested settings table with defaults
[3] = function(data)
data.settings = data.settings or {
musicOn = true,
sfxVolume = 0.8,
language = "en",
}
return data
end,
},
defaultData = {
coins = 0,
experience = 0,
level = 1,
inventory = {},
settings = {
musicOn = true,
sfxVolume = 0.8,
language = "en",
},
},
})
What happens on load:
- A brand-new player receives
defaultDatawith_schemaVersion = 3. No migrations run. - A v0 player (no
_schemaVersionfield) runs migrations 1 → 2 → 3 sequentially. - A v2 player only runs migration 3.
- A v3 player skips migrations entirely — returned from cache or DataStore as-is.
Caching
How the Cache Works
The Cache module implements an LRU (Least Recently Used) cache
backed by a doubly-linked list and a hash map for O(1) reads, writes, and promotions.
- TTL expiration — Each entry has a timestamp. On read, if the entry is older than
ttlseconds (default 60), it's lazily evicted and a cache miss is recorded. - Dirty tracking — Entries written locally but not yet persisted to DataStore are flagged as dirty.
SaveAlliterates only dirty entries for efficient flushing. - LRU eviction — When the cache exceeds
maxEntries(default 500), the least-recently-used non-dirty entry is evicted. Dirty entries are never evicted — if all entries are dirty, a warning is logged and eviction is skipped. - Deep-copy isolation — Data is deep-copied on every cache read and write so that external mutations never corrupt internal state.
Tuning Settings
| Setting | Default | When to Change |
|---|---|---|
cacheTTL (DataStoreManager) / ttl (Cache) |
60 |
Decrease if data changes frequently from external sources (e.g., admin commands). Increase or set to 0 (infinite) if data only changes within the server that loaded it. |
maxEntries (Cache standalone) |
500 |
Increase for games with very high concurrent player counts. The default of 500 is generous for most Roblox servers (max 100 players per server, but cache may hold recently-departed players). |
Monitoring
-- DataStoreManager exposes cache-related metrics:
local metrics = manager:GetMetrics()
print("Hit rate:", metrics.cacheHits / (metrics.cacheHits + metrics.cacheMisses))
-- The standalone Cache module has its own stats:
local cache = Cache.new({ ttl = 120, maxEntries = 200 })
local stats = cache:GetStats()
-- stats = { entries = 42, dirtyCount = 3, hitRate = 0.85 }
Budget Management
Roblox Rate Limits
Roblox imposes per-server request budgets on DataStore operations. Budgets regenerate over time, and if you exceed them your calls will be throttled or fail. The exact rates depend on the number of players in the server.
How the Kit Handles It
Before every DataStore call (GetAsync, SetAsync,
UpdateAsync, RemoveAsync), the kit calls
DataStoreService:GetRequestBudgetForRequestType() to check the remaining
budget. If the budget is zero, the kit yields the current thread and
polls every second until at least one credit is available, then proceeds with the
request.
This means retries never waste budget on already-throttled requests, and your game won't error-spam the output when traffic is high. The kit logs a warning when throttling occurs so you can monitor it:
[DataStorePro] Request budget exhausted, throttling {type=SetIncrementAsync}
Inspecting Budgets at Runtime
local budget = manager:GetBudget()
print("Get budget:", budget.get) -- e.g., 60
print("Set budget:", budget.set) -- e.g., 60
print("Update budget:", budget.update) -- e.g., 60
print("List budget:", budget.list) -- e.g., 5
Troubleshooting
Data Not Saving
| Symptom | Likely Cause | Solution |
|---|---|---|
| Data resets every time the player joins | You're not calling :Set() in PlayerRemoving or :SaveAll() in BindToClose. |
Ensure both are wired up. See the Quick Start. |
[DataStorePro] [ERROR] Set failed after 5 attempts |
DataStore is throttled or experiencing an outage. | The entry is marked dirty — SaveAll in BindToClose will retry. Consider increasing retryAttempts if this happens often. |
| Data saves in Studio but not in a live server | API access for DataStores may be disabled for the published place. | In Game Settings → Security, enable Enable Studio Access to API Services. For live games, ensure the place is published and DataStore access is on. |
| Mutations to the returned data table aren't persisted | Data is deep-copied on :Get(). Mutating your local copy doesn't affect the cache. |
Store the data in your own table, mutate it, then call :Set(playerId, data) to persist. |
Session Lock Conflicts
| Symptom | Likely Cause | Solution |
|---|---|---|
[SessionLock] Timed out acquiring lock for player 12345 |
Another server is still saving this player's data and hasn't released the lock within acquireTimeout. |
Increase acquireTimeout (default 15 s) or ensure your PlayerRemoving handler finishes quickly and calls :Release(). |
| Players can never join after a server crash | The lock wasn't released before the server died. | This self-heals — locks auto-expire after lockTTL (default 30 s). If players are still stuck, the heartbeat may have refreshed just before the crash. Wait for the full TTL. |
[SessionLock] Could not write lock … allowing access |
MemoryStoreService is unavailable (e.g., in Studio or during an outage). | This is a graceful fallback — the kit allows access rather than blocking every player. In production, MemoryStore outages are rare and brief. |
Migration Errors
| Symptom | Likely Cause | Solution |
|---|---|---|
[Migration] Missing migration function for version 2 |
You set schemaVersion = 3 but didn't provide a migration for version 2. |
Ensure your migrations table has a function for every version from 1 to schemaVersion. |
[Migration] Migration function for version 2 did not return a table |
Your migration function forgot to return data. |
Every migration function must return the data table. |
| Data seems to not migrate — old fields still present | The data's _schemaVersion is already at or above your schemaVersion. |
Bump schemaVersion and add a new migration function whenever you change the schema. |
[Migration] Data version (5) is ahead of current version (3) |
You rolled back to an older version of your code. | This is a warning — the data is returned as-is. Avoid rolling back schemaVersion in production. |
General Tips
- Use
manager:GetMetrics()to monitor error and retry rates. - Use
manager:GetBudget()to check if you're near DataStore rate limits. - Always test in a Studio Team Test (multiple clients) to verify session locking works.
- Keep migration functions simple and idempotent — use
data.field = data.field or defaultpatterns. - Never modify the
_schemaVersionfield manually in your game code.