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.
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,
SetAsyncandUpdateAsyncfail 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:JSONEncodebefore 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.