How to stop per-player tables leaking memory on your server
Tables keyed by Player or UserId that are never cleared grow for the life of the server. How to clean them up in PlayerRemoving.
What it looks like
Server scripts keep tables keyed by Player, UserId or name, for cooldowns, sessions, caches or stats, and add an entry when a player joins or first does something, but nothing removes it when they leave.
Luau
local lastUse = {} remote.OnServerEvent:Connect(function(player) lastUse[player] = os.clock() end)
How to detect it
- List every table at the top level of a server script or module whose keys are players,
UserIds or names, including ones filled through helpers. - For each, look for the matching cleanup in
Players.PlayerRemoving, or a loop that prunes old entries. A table that only ever grows is a finding. - Tables keyed by
Playerhold thePlayerobject itself. Roblox's performance guide warns that aPlayerand their character aren't destroyed automatically when they leave unlessWorkspace.PlayerCharacterDestroyBehavioris enabled, so the entry keeps everything it references in memory too. - Connections to a player's own events, such as
CharacterAdded, have the same problem when nothing disconnects them; report those as Connections and loops that pile up. - Check the cleanup runs for players who left before a slow setup finished, for example after
PlayerData.waitForreturns, by checking the player is still in the game before adding the entry. - Bounded caches with an expiry, or tables keyed by something that can only have a few values, aren't a leak.
What an exploiter could do
Each join adds entries that never go away. Over a long-running server, hundreds of players coming and going grow the server's memory until it slows down or crashes. A player can speed this up by rejoining again and again. Roblox's guide notes leaks matter most on servers because they can run for days.
How to fix it
- Clear every per-player entry in one
PlayerRemovinghandler per script. - Before adding an entry after a yield, check
player.Parent == Players, so a player who already left doesn't leave one behind. - Enable
Workspace.PlayerCharacterDestroyBehaviorso leaving players and old characters are destroyed and their connections disconnected.
Vulnerable example
Vulnerable
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local cooldown = 2 local joinedAt: { [Player]: number } = {} local lastWave: { [Player]: number } = {} Players.PlayerAdded:Connect(function(player) joinedAt[player] = os.time() end) ReplicatedStorage.Remotes.Wave.OnServerEvent:Connect(function(player: Player) local now = os.clock() if now - (lastWave[player] or 0) < cooldown then return end lastWave[player] = now player:SetAttribute("SessionMinutes", (os.time() - (joinedAt[player] or os.time())) // 60) end)
Fixed example
Fixed
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local cooldown = 2 local joinedAt: { [Player]: number } = {} local lastWave: { [Player]: number } = {} Players.PlayerAdded:Connect(function(player) joinedAt[player] = os.time() end) ReplicatedStorage.Remotes.Wave.OnServerEvent:Connect(function(player: Player) local now = os.clock() if now - (lastWave[player] or 0) < cooldown then return end lastWave[player] = now player:SetAttribute("SessionMinutes", (os.time() - (joinedAt[player] or os.time())) // 60) end) Players.PlayerRemoving:Connect(function(player) joinedAt[player] = nil lastWave[player] = nil end)
Common false positives
- Tables cleared somewhere else, such as in a shared module's
PlayerRemovinghandler or a cleanup function called from one, are fine. Follow the calls. - Tables keyed by fixed things, such as item names or zone names, don't grow with players.
- A table that's only ever read from saved data and replaced as a whole, not added to per player, doesn't grow.