How to stop player data outgrowing the data store size limit

Builds, drawings and lists with no cap can grow past the 4 MB limit and stop a profile saving. How to cap and bound what players add to saved data.

warningLuaudit check: unbounded-saved-data

What it looks like

Something the player controls is added to saved data with no cap on how many entries it can have or how long they can be: builds, placed items, drawings, history lists, or keys taken from remote arguments.

Luau
table.insert(profile.Builds, build)

How to detect it

  • Find every list or dictionary in saved data that grows during play, and trace what adds to it.
  • A data store value can be at most 4,194,304 characters once serialised to JSON. Past that, SetAsync and UpdateAsync fail with a "value too large" error on every attempt.
  • Look for a cap on the number of entries, a length limit on strings, and bounds on any numbers used to build keys. Without them, a remote that adds one entry per call grows the record for as long as someone keeps calling it.
  • Check what else shares the key. If the growing data sits inside the main profile, then once it's too large the whole profile stops saving: coins, items and purchases too.
  • Shared keys, such as a server-wide board or a guild's storage, are worse: one player can fill them for everyone.

What an exploiter could do

An exploiter can call the remote over and over until the record is too large to save. If the data shares a key with anything else, that stops saving as well, and every later save fails until the data is cut down by hand. On a shared key, one player can stop saves for a whole guild or the whole game. Ordinary players who build a lot can hit the limit too, without meaning to.

How to fix it

  • Cap every list and dictionary that players add to. Reject new entries at the cap rather than dropping old ones without telling the player.
  • Bound coordinates and indexes to a fixed grid, and limit the length of any string that's saved.
  • Keep large, player-made data, such as builds, in its own key so a problem there can't block the main profile from saving.
  • If the data could still get close to the limit, check its size with HttpService:JSONEncode before saving, and handle a failure there instead of retrying forever.

Vulnerable example

Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

ReplicatedStorage.Remotes.PlaceBlock.OnServerEvent:Connect(function(player: Player, block: unknown, position: unknown)
	if typeof(block) ~= "string" or typeof(position) ~= "Vector3" then
		return
	end
	local profile = PlayerData.get(player)
	if not profile then
		return
	end
	table.insert(profile.Builds, { block = block, x = position.X, y = position.Y, z = position.Z })
end)

Fixed example

Fixed
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local blocks = { Wood = true, Stone = true, Glass = true }
local maxBlocks = 500
local plotSize = 64
local cooldown = 0.2
local lastPlace: { [Player]: number } = {}

local function onGrid(value: number): boolean
	return value == value and value % 1 == 0 and value >= 0 and value < plotSize
end

ReplicatedStorage.Remotes.PlaceBlock.OnServerEvent:Connect(function(player: Player, block: unknown, position: unknown)
	if typeof(block) ~= "string" or not blocks[block] or typeof(position) ~= "Vector3" then
		return
	end
	if not onGrid(position.X) or not onGrid(position.Y) or not onGrid(position.Z) then
		return
	end
	local profile = PlayerData.get(player)
	local now = os.clock()
	if not profile or #profile.Builds >= maxBlocks or now - (lastPlace[player] or 0) < cooldown then
		return
	end
	lastPlace[player] = now
	table.insert(profile.Builds, { block = block, x = position.X, y = position.Y, z = position.Z })
end)

Players.PlayerRemoving:Connect(function(player)
	lastPlace[player] = nil
end)

Common false positives

  • Lists that only the server adds to with a natural ceiling, such as unlocked items from a fixed catalogue, can't grow past that catalogue.
  • Data that's already capped by another rule, such as one entry per owned plot slot, is bounded even without a count check. Say where the bound comes from.
  • A log the server trims to the latest few entries before saving is bounded.