Your game runs perfectly for the first 10 minutes. After an hour, servers stutter and memory keeps climbing. Nothing looks broken.
Quick answer: You're creating event connections and never disconnecting them. Every leaked connection keeps running (and keeps its data alive) forever.
What is a leaked connection?
Every :Connect() returns a connection object. If the thing you connected to lives longer than your logic, the connection stays alive.
-- ❌ Runs every time a player spawns
local function onCharacterAdded(character)
RunService.Heartbeat:Connect(function()
updateHealthBar(character)
end)
endRunService.Heartbeat lives forever. When the character dies, that function keeps firing every frame for a character that no longer exists. After 50 respawns, you have 50 loops running for nothing.
Fix 1: Store and disconnect
local function onCharacterAdded(character)
local connection
connection = RunService.Heartbeat:Connect(function()
updateHealthBar(character)
end)
character.Humanoid.Died:Once(function()
connection:Disconnect()
end)
endRule of thumb: if you connect to something global, you must disconnect. Global means RunService, UserInputService, Players, Workspace, and so on.
Fix 2: Use Once when you only need one event
-- Disconnects itself after the first call
button.Activated:Once(function()
startGame()
end)Fix 3: Clean up per-player tables
A very common leak: a table indexed by player that never gets cleared.
local cooldowns = {}
Players.PlayerRemoving:Connect(function(player)
cooldowns[player] = nil
end)Without that, the Player object (and everything inside it) can never be garbage collected.
Fix 4: Group connections in a cleanup list
When you have many, track them together.
local connections = {}
table.insert(connections, RunService.Heartbeat:Connect(update))
table.insert(connections, UserInputService.InputBegan:Connect(onInput))
local function cleanup()
for _, conn in connections do
conn:Disconnect()
end
table.clear(connections)
endLibraries like Janitor and Trove do exactly this with extra features, if you'd rather not roll your own.
Good news: Destroy() helps
Calling :Destroy() on an instance disconnects all connections to that instance's own events. So connecting to part.Touched and then destroying the part is fine. The leaks come from connecting to other objects that outlive your logic.
How to spot leaks
- Open the Developer Console (F9) → Memory tab and watch whether it keeps growing in a long test.
- Look at Scripts activity: a script with rising activity and no reason for it is usually a leaked loop.
- Search your code for
Heartbeat:Connect,RenderStepped:ConnectandInputBegan:Connectinside functions that run more than once.
Checklist
- ✅ Every global
:Connect()has a matching:Disconnect() - ✅ One-shot events use
:Once() - ✅ Tables keyed by player are cleared on
PlayerRemoving - ✅ Character-related connections die with the character