How to stay within data store request limits
Saving on every coin, kill or second played uses up the server's data store budget and gets saves dropped. How to keep values in memory and save on a timer.
What it looks like
The game writes to a data store each time a value changes, such as on every coin picked up, every kill or every second played, instead of keeping the value in memory and saving it now and then.
Luau
coin.Touched:Connect(function() store:IncrementAsync(key, 1) end)
How to detect it
- Find every data store call and what triggers it. Calls inside
Touched,Heartbeat,RenderStepped, shortwhileloops, remote handlers or per-hit combat code are the ones to look at. - Roblox gives each server a request budget per minute for each request type. For standard data stores, reads and writes each get
60 + numPlayers × 40; ordered data store writes get30 + numPlayers × 5.UpdateAsyncuses both read and write budget. - Work out the rate. A write per player per second is 60 a minute for each player, more than the 40 a minute each extra player adds to the write budget, so with enough players the server runs out.
- When the budget runs out, requests wait in a queue. Each queue holds 30 requests, and further requests are dropped with an error. Standard writes share one queue, so a busy counter can push out the save that matters, such as the player's profile on leave.
- A remote that writes to a data store on every call with no cooldown is also a finding under Remote with no rate limit. Report it there too if the client can trigger it.
What an exploiter could do
Mostly no exploit is needed. As servers fill up, data store requests are delayed and then dropped, and the dropped ones can include profile saves on leave or at shutdown, so players lose progress. If a remote triggers the writes, an exploiter can fire it to use up the server's budget for everyone.
How to fix it
- Keep the value in a server table and change it in memory. Roblox's own guidance is to update the local copy instead of sending a request for every change.
- Save on an autosave every few minutes, when the player leaves and in
BindToClose. - For a counter that several servers add to, add the amount gathered since the last save with one
IncrementAsync, or withUpdateAsyncadding to the old value. - If you need to know how much budget is left, check
DataStoreService:GetRequestBudgetForRequestType.
Vulnerable example
Vulnerable
local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") local kills = DataStoreService:GetDataStore("Kills") local function trackKills(player: Player, character: Model) local humanoid = character:WaitForChild("Humanoid") :: Humanoid humanoid.Died:Connect(function() local tag = humanoid:FindFirstChild("Killer") local killer = if tag and tag:IsA("ObjectValue") then tag.Value else nil if killer and killer:IsA("Player") and killer ~= player then pcall(function() kills:IncrementAsync(tostring(killer.UserId), 1) end) end end) end Players.PlayerAdded:Connect(function(player) player.CharacterAdded:Connect(function(character) trackKills(player, character) end) end)
Fixed example
Fixed
local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") local kills = DataStoreService:GetDataStore("Kills") local unsaved: { [Player]: number } = {} local saving = 0 local function save(player: Player) local count = unsaved[player] if not count or count == 0 then return end unsaved[player] = 0 saving += 1 for attempt = 1, 3 do local ok = pcall(function() kills:IncrementAsync(tostring(player.UserId), count) end) if ok then break end task.wait(attempt) end saving -= 1 end local function trackKills(player: Player, character: Model) local humanoid = character:WaitForChild("Humanoid") :: Humanoid humanoid.Died:Connect(function() local tag = humanoid:FindFirstChild("Killer") local killer = if tag and tag:IsA("ObjectValue") then tag.Value else nil if killer and killer:IsA("Player") and killer ~= player and unsaved[killer] then unsaved[killer] += 1 end end) end Players.PlayerAdded:Connect(function(player) unsaved[player] = 0 player.CharacterAdded:Connect(function(character) trackKills(player, character) end) end) Players.PlayerRemoving:Connect(function(player) save(player) unsaved[player] = nil end) game:BindToClose(function() for _, player in Players:GetPlayers() do task.spawn(save, player) end while saving > 0 do task.wait() end end) task.spawn(function() while true do task.wait(180) for _, player in Players:GetPlayers() do task.spawn(save, player) end end end)
Common false positives
- A write on a rare, important event, such as a purchase, a trade or a level-up, is fine and often recommended.
- A single write per player when they join, leave or reach a checkpoint isn't "every change".
- Reads that
GetAsyncserves from its few-second cache don't count towards the budget. Repeated reads of the same key within a few seconds cost less than they look.