You ship your Roblox game. Players start joining. Someone posts in your Discord: "I lost all my coins." Then another player. Then five more.
You open your DataStore code and realize the problem: you're writing player data on PlayerRemoving, but when the server crashes — or when the player's connection drops before the event fires — that final save never happens. The data is gone.
This is the most common data loss bug in Roblox games, and it has a name: a session lock failure. Fixing it is what ProfileService and DataStore2 are both designed to do. But they solve it differently, have different tradeoffs, and in 2026, one of them is clearly the better choice for new projects.
This article gives you the full picture, with real implementation code, so you can make the right call.
Why Raw DataStores Are Dangerous
Roblox's built-in DataStoreService is a low-level key-value store. It gives you GetAsync, SetAsync, UpdateAsync — and nothing else. You're responsible for:
- Retry logic when the DataStore API returns throttle errors or 500s
- Session locking to prevent two servers from overwriting each other's data (this happens during teleports and server restarts)
- Atomic updates to avoid race conditions
- Automatic backups in case of corruption
Most developers don't implement all four. Most data loss bugs come from missing one of them.
Here's a minimal raw DataStore implementation that looks fine but isn't:
-- ❌ DANGEROUS: Missing retry logic, no session locking
local DataStoreService = game:GetService("DataStoreService")
local playerStore = DataStoreService:GetDataStore("PlayerData")
game.Players.PlayerAdded:Connect(function(player)
local success, data = pcall(function()
return playerStore:GetAsync(player.UserId)
end)
if success and data then
-- load data
end
end)
game.Players.PlayerRemoving:Connect(function(player)
local success, err = pcall(function()
playerStore:SetAsync(player.UserId, { coins = 100 })
end)
if not success then
warn("Data save failed:", err) -- data is lost. You only warned.
end
end)This code will silently lose data on every server crash. PlayerRemoving doesn't fire when a server shuts down — game:BindToClose does, and even then you have a 30-second execution window that may not be enough for all players.
DataStore2: The First Major Solution (2018–2022)
DataStore2, created by Kampfkarren, was the community's go-to solution for years. It wraps Roblox's DataStore in a safer interface with:
- Automatic caching (data is saved to memory on load, preventing repeat
GetAsynccalls) - Backup DataStores (if the primary store fails, DataStore2 falls back to a secondary)
BeforeSavehooks for data transformation before persistence
-- DataStore2 basic usage
local DataStore2 = require(game.ServerScriptService.DataStore2)
DataStore2.Combine("DATA", "coins", "level") -- combines into one key for efficiency
game.Players.PlayerAdded:Connect(function(player)
local coinsStore = DataStore2("coins", player)
-- Get with a default value
local coins = coinsStore:Get(0)
-- Save (automatically called on PlayerRemoving)
coinsStore:Set(coins + 100)
end)DataStore2 is mature, well-documented, and used in thousands of games. But it has problems that became more apparent as games scaled:
- No session locking. Two servers can load the same player's data simultaneously during a teleport. The last save wins, and data from one session can be silently overwritten.
- No built-in reconciliation. If your data schema changes (you add a new field), you have to manually handle migration. DataStore2 won't fill in missing keys with defaults.
- Active development has slowed. The library hasn't had major updates since 2022. Bugs are reported but not always fixed.
DataStore2 is still a significant improvement over raw DataStores. But in 2026, ProfileService is the better foundation for any new game.
ProfileService: The Current Standard
ProfileService, created by MadStudioRoblox, takes a fundamentally different approach. It was designed from the start around one principle: a player's data should only be loaded on one server at a time.
That's session locking. ProfileService implements it natively using a Roblox DataStore key as a "lock token." When a profile is loaded, the lock is set. If another server tries to load the same profile while the lock is active, ProfileService detects it, waits, and retries — or releases the profile if the locking server is confirmed dead.
Setting Up ProfileService
First, require the module (available via Wally or the Roblox toolbox):
-- ServerScriptService/DataManager.lua (ModuleScript)
local ProfileService = require(game.ServerScriptService.ProfileService)
local Players = game:GetService("Players")
-- Define your data schema with defaults
local PROFILE_TEMPLATE = {
Coins = 0,
Level = 1,
Inventory = {},
TotalPlaytime = 0,
}
local ProfileStore = ProfileService.GetProfileStore(
"PlayerData_v1", -- store name — version this when you wipe data
PROFILE_TEMPLATE
)
local Profiles = {} -- UserId -> Profile
local function onPlayerAdded(player: Player)
local profile = ProfileStore:LoadProfileAsync(
tostring(player.UserId),
"ForceLoad" -- kicks other servers holding the lock
)
if profile == nil then
-- Profile couldn't be loaded (DataStore outage, etc.)
-- Always kick the player — never let them play without loaded data
player:Kick("Data failed to load. Please rejoin.")
return
end
profile:AddUserId(player.UserId) -- GDPR compliance
profile:Reconcile() -- fills in any missing keys from PROFILE_TEMPLATE
profile:ListenToRelease(function()
-- Another server took the session lock
Profiles[player.UserId] = nil
player:Kick("Your session was loaded elsewhere. Please rejoin.")
end)
if player.IsDescendantOf(Players) then
Profiles[player.UserId] = profile
else
-- Player left before load completed
profile:Release()
end
end
local function onPlayerRemoving(player: Player)
local profile = Profiles[player.UserId]
if profile then
profile:Release() -- releases the session lock and saves
end
end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(onPlayerRemoving)
-- Handle players already in game when this script runs
for _, player in Players:GetPlayers() do
task.spawn(onPlayerAdded, player)
end
-- Public API for other scripts
local DataManager = {}
function DataManager.GetProfile(player: Player)
return Profiles[player.UserId]
end
function DataManager.GetData(player: Player)
local profile = Profiles[player.UserId]
return if profile then profile.Data else nil
end
return DataManagerReading and Writing Data From Other Scripts
-- ServerScriptService/CoinService.lua (Script)
local DataManager = require(game.ServerScriptService.DataManager)
local Players = game:GetService("Players")
local function addCoins(player: Player, amount: number)
local data = DataManager.GetData(player)
if not data then
warn("Tried to add coins for player with no loaded data:", player.Name)
return
end
data.Coins += amount
-- Fire a RemoteEvent to update the client UI
-- (Never trust the client to track their own coin count)
end
-- Example: award coins on kill
game.ReplicatedStorage.Remotes.PlayerKilled.OnServerEvent:Connect(function(player, _victim)
addCoins(player, 50)
end)Notice the pattern: you never call DataStoreService directly in your game logic. All data access goes through DataManager, which controls the profile lifecycle. This is the clean architecture that ProfileService enables.
Head-to-Head: Feature Comparison
| Feature | Raw DataStore | DataStore2 | ProfileService |
|---|---|---|---|
| Session locking | ❌ None | ❌ None | ✅ Native |
| Retry logic | ❌ Manual | ✅ Built-in | ✅ Built-in |
| Default value reconciliation | ❌ Manual | ⚠️ Partial | ✅ Reconcile() |
| Schema versioning | ❌ Manual | ❌ Manual | ✅ Store name versioning |
| Active maintenance (2026) | ✅ First-party | ⚠️ Slowing | ✅ Active |
| Teleport safety | ❌ | ❌ | ✅ ForceLoad / lock handoff |
| GDPR compliance helper | ❌ | ❌ | ✅ AddUserId() |
| Learning curve | Low | Medium | Medium-High |
The One Scenario Where DataStore2 Still Wins
If you have an existing game with DataStore2 data and hundreds of thousands of players, migrating is genuinely risky. DataStore2 and ProfileService use different key formats. A migration script is possible but requires careful testing and a staged rollout.
In that case: keep DataStore2, add the missing retry and session-lock logic manually, and plan a full ProfileService migration for your next major version.
For any new project, use ProfileService from day one.
Common Pitfalls to Avoid
1. Saving data too frequently
ProfileService auto-saves every few minutes, and saves on Release(). Don't call Save() manually on every data change — it will get you throttled.
-- ❌ Don't do this
data.Coins += 1
profile:Save() -- called hundreds of times per session2. Trusting the client
The client should never tell the server what their coin count is. The server holds the source of truth. Clients send actions ("I collected this coin"), not states ("I now have 500 coins").
3. Not handling nil profiles
LoadProfileAsync can return nil during DataStore outages. Always kick the player gracefully. Never let someone play with unloaded data — you'll overwrite their real data with defaults on save.
if profile == nil then
player:Kick("Could not load data. Please try again.")
return
end4. Forgetting Reconcile()
When you add new fields to your PROFILE_TEMPLATE, existing players won't have those keys in their saved data. Reconcile() fills them in automatically. Call it every time you load a profile, right after the nil check.
Migrating From DataStore2 to ProfileService
If you're migrating an existing game:
- Never delete old data. Rename your ProfileService store (
"PlayerData_v2") so you can fall back. - Write a migration script that loads old DataStore2 data and writes it into the new ProfileService format on first join.
- Test with a place copy, not production.
- Run parallel saves for a week: write to both systems simultaneously, compare outputs, then cut over.
-- Migration helper (run once per player on first load)
local function migrateIfNeeded(player: Player, profile)
if profile.Data.MigrationComplete then return end
local DataStore2 = require(path.to.DataStore2)
local oldCoinsStore = DataStore2("coins", player)
local oldCoins = oldCoinsStore:Get(0)
profile.Data.Coins = oldCoins
profile.Data.MigrationComplete = true
print("Migrated", player.Name, "— Coins:", oldCoins)
endVerdict
ProfileService is the right choice for Roblox game data in 2026. Session locking alone is worth the slightly steeper setup. Your players' data is worth more than the hour it takes to learn the API.
If you're building anything beyond a test project, implement ProfileService now — before your player count makes the migration scary.
I cover more Roblox Studio architecture patterns in how to write production-quality Luau code and designing for player retention from the start. If you're building a game and want to talk architecture, find me at mattqdev.