← All Docs DataStore Pro Kit

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:

The kit ships four modules — DataStoreManager (core), SessionLock, Migration, and Cache — that work together or independently.

Installation

  1. Download the .rbxm model file from your Baseplate purchase page.
  2. In Roblox Studio, right-click ServerStorageInsert from File… and select the .rbxm.
  3. You should see a folder structure like:
    ServerStorage
      └─ DataStoreProKit
           ├─ DataStoreManager   (ModuleScript)
           ├─ SessionLock         (ModuleScript)
           ├─ Migration           (ModuleScript)
           └─ Cache               (ModuleScript)
  4. 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:

FieldTypeDefaultDescription
namestringrequiredDataStore name passed to DataStoreService:GetDataStore().
retryAttemptsnumber?5Maximum retry attempts per DataStore call.
retryDelaynumber?1Base delay in seconds for exponential backoff.
cacheTTLnumber?60Seconds before a cached entry expires. 0 = infinite.
schemaVersionnumber?1Current 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

MethodParametersReturnsDescription
: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

MethodParametersReturnsDescription
: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)

FieldTypeDefaultDescription
dataStoreNamestringrequiredMust match the DataStoreManager's name so locks correspond to the correct store.
lockTTLnumber?30Seconds before an un-refreshed lock auto-expires (crash recovery).
heartbeatIntervalnumber?10Seconds between heartbeat refreshes that keep the lock alive.
acquireTimeoutnumber?15Max seconds to wait for another server to release the lock.
acquireRetryDelaynumber?1Seconds between acquire retry attempts.

SessionLock Methods

MethodParametersReturnsDescription
: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)

FieldTypeDefaultDescription
currentVersionnumberrequiredTarget schema version (must be ≥ 1).
migrations{ [number]: MigrationFn }requiredMap of version → (data) → data transform functions.

Migration Methods

MethodParametersReturnsDescription
: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?)

FieldTypeDefaultDescription
ttlnumber?60Seconds before an entry expires.
maxEntriesnumber?500Maximum entries before LRU eviction kicks in.

Cache Methods

MethodParametersReturnsDescription
: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:

  1. Server A starts saving Player_123's data.
  2. Server B loads Player_123's data (gets the old copy).
  3. Server A's save completes — writes newer data.
  4. 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:

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:

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

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:

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.

Tuning Settings

SettingDefaultWhen 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

SymptomLikely CauseSolution
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

SymptomLikely CauseSolution
[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

SymptomLikely CauseSolution
[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