The Luau Garbage Collector
Luau is garbage-collected, which sounds simple until your script's memory usage climbs steadily over an hour-long session and you have to figure out why. This article covers the design of the Luau GC — incremental mark-and-sweep — along with weak tables, the __gc metamethod, and the most common ways scripts accidentally hold on to memory they don't need.
Incremental mark-and-sweep
Luau's GC is a classical tracing collector — it follows references from a set of roots (the Lua registry, currently- running coroutines, etc.) and marks everything reachable. Anything unmarked at the end is unreachable, and gets freed.
The "incremental" part is the practical wrinkle. A naive mark-and-sweep stops the world while it traces, which on a large heap produces noticeable pauses. Luau instead interleaves small slices of marking work with normal execution. A typical step might mark a few hundred objects; across many steps, the full graph eventually gets traced. The result is fewer pauses, at the cost of slightly higher steady-state overhead.
You don't usually have to think about any of this. It becomes relevant in three situations: when you have memory leaks (the GC can't free what's still reachable), when you have GC stalls (lots of allocation in one frame triggers extra work), and when you're writing finalizers via __gc (which run on collection, in a special phase).
What counts as a root
A reference is "reachable" if there's a path to it from one of:
- The global table of any running script.
- A local variable in any active coroutine's call stack.
- The registry — used internally for things like signal connection lists.
- Any C-side reference held by the host (Roblox engine, executor, etc.).
If a value is reachable from any of these, the GC won't touch it, no matter how "dead" it looks logically. That asymmetry — what the GC sees vs what you intended — is the source of every leak.
Weak tables: telling the GC to ignore references
A weak table is one where the GC pretends the table's entries don't count toward reachability. You opt in by setting the __mode metatable field.
local cache = setmetatable({}, { __mode = "v" }) -- weak values
local cache2 = setmetatable({}, { __mode = "k" }) -- weak keys
local cache3 = setmetatable({}, { __mode = "kv" }) -- bothPractical example: per-instance scratch data. You want a map from instance to a derived value, but you don't want the map to keep instances alive after they're removed from the world.
local trail = setmetatable({}, { __mode = "k" })
local function addTrail(part: BasePart)
trail[part] = { startedAt = tick(), points = {} }
end
-- When 'part' is Destroyed and unreachable elsewhere, the GC removes
-- trail[part] automatically — no manual cleanup needed.The classic mistake without weak keys: a global table that maps instance → data, indexed during the instance's lifetime and never explicitly cleared. Instance gets destroyed, the entry stays, the destroyed instance never truly leaves memory because the table holds it. The fix is either to clear entries on a .Destroying signal, or to use a weak-keyed table.
The __gc metamethod
Tables can have a finalizer — a function called when the table is about to be collected. Useful for releasing external resources tied to the table's lifetime.
local handle = newproxy(true)
local mt = getmetatable(handle)
mt.__gc = function()
-- Called when 'handle' is collected
print("closing file")
closeFile(myFile)
end
-- handle is local to a function; when the function returns and the
-- coroutine moves on, handle becomes unreachable, and the GC eventually
-- runs the __gc finalizer.Two caveats. Finalizers fire eventually, not at the moment of dereference — anything that needs prompt cleanup should be explicit, not GC-driven. And the order of finalizer execution isn't guaranteed, so don't assume A's __gc runs before B's.
Common leak patterns
The four leak patterns we see most:
- Forgotten connections. A signal connection holds a reference to the function and often to upvalues it captured. If those upvalues include an instance you thought was dead, it stays alive as long as the connection does. Always
:Disconnect()on cleanup, or use:Once()for one-shot handlers. - Global tables that never shrink. A
_G.activePlayers[player.UserId] = ...that never deletes onPlayerRemoving. The UserIds aren't expensive on their own, but thevalues often contain heavy state. - Closures over instances. A timer that captures a part and keeps it alive past destruction.
task.delay(60, function() doStuffWith(part) end)holdspartfor the full minute. Cancel the delay on destruction or checkpart.Parent ~= nilinside. - Cyclic strong references. Rare in pure Luau (the GC handles cycles), but possible across the script/C boundary when scripts attach userdata references back to instances the engine also tracks.
Diagnosing leaks
The crude but effective approach: take a snapshot of memory usage with game:GetService("Stats"):FindFirstChild("LuaHeap") or collectgarbage("count") at known times in your session, and see how it grows.
local Stats = game:GetService("Stats")
task.spawn(function()
while true do
task.wait(10)
print(("LuaHeap: %.1f MB"):format(Stats:FindFirstChild("LuaHeap").Value / 1024 / 1024))
end
end)For server-side measurements, Studio's built-in script performance pane gives more detail, including allocation source. For client-side leak hunting, the Developer Console's memory tab on a published game is your friend.
Tuning hints
Roblox doesn't expose collectgarbage("collect") to scripts in most contexts — the engine controls when the GC runs, on the principle that scripts shouldn't be able to introduce long pauses. What you can influence:
- Reduce allocation rate. The GC runs more when there's more to collect. Tight loops that build new tables every iteration are the most common culprit.
- Reuse buffers. If a hot path needs a temporary table, reuse a single module-level one with
table.clearinstead of allocating fresh. - Prefer numbers over tables. A
Vector3is a value type and gets stack-allocated cheaply; an equivalent{x=1,y=2,z=3}is a heap allocation.
Wrap-up
The GC's job is invisible 99% of the time. When it becomes visible — climbing memory, frame stutters, a hot loop suddenly slow — the cause is almost always a reachability accident: something the script logically doesn't need, but the GC can still reach. Knowing where references come from, and where weak tables can quietly drop them, is the single most useful skill for fixing it.
Ather is the lead developer behind Atherhub. He's been writing Luau and Roblox tooling for the better part of a decade, with a focus on the messy interface between game-script internals and the platforms that host them. Have feedback on this article? Drop it in the Discord.