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.

warningLuaudit check: request-every-change

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, short while loops, 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 get 30 + numPlayers × 5. UpdateAsync uses 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 with UpdateAsync adding 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 GetAsync serves 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.