Skip to content

GcWatch

GcWatch derives garbage-collector health from the only signal Roblox exposes — the heap size — and turns it into allocation rates, cycle cadence, and reclaim sizes. It’s a shared module: it attaches to a server kernel or a client kernel the same way. For the sibling module that captures script errors instead of GC health, see ErrorWatch.

Roblox exposes no GC pause timingscollectgarbage is restricted to "count", and there is no callback for collection events. What is readable is gcinfo(), the current Lua heap in KB. GcWatch samples it on a recurring Scheduler task (default every 0.1s, at Priority.Background) and reconstructs GC behavior from the deltas between samples:

  • Heap grew — allocation happened. The growth accumulates into the current rate window. If the heap had been shrinking until now, the grow marks the end of one GC cycle: the cycle count increments and the shrink run’s total becomes LastReclaimKb.
  • Heap shrank — a sweep is reclaiming. Consecutive shrinking samples accumulate into one reclaim run; the run stays open until growth resumes.
  • Every window (default 5s), the accumulated allocation and cycle counts convert into AllocKbPerSec and CyclesPerMin, then the window resets. Rates describe the last window, not a forever-average — a leak that started ten minutes in shows up in the next window, not diluted across the session.

The numbers are approximate by nature: Luau’s GC is incremental, so sweeps interleave with allocation and one sample interval can net a grow and a shrink into a single delta. The shape is what matters, and the shape is reliable — the specs drive the sampler with injected heap curves and assert exact cycle bookkeeping.

Allocation rate is the lever. Every KB allocated must later be marked and swept by the incremental GC, and allocation spikes trigger GC assists inside your frames. Cut allocation and you cut GC time — there is no other knob.

The two stats together separate the two memory failure modes:

Heap curve Cycles / reclaims Diagnosis
Climbing Reclaims near zero Leak — the GC runs but has nothing to free. Look for insert-only tables, undisconnected connections, per-player state not cleared (session:bind exists for exactly this).
Stable Busy cycles, steady reclaims Churn — tables/strings created per frame and thrown away. Pool or hoist them; see Pool.
-- src/Server/Bootstrap.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GcWatch = require(ReplicatedStorage.ChloeKernel.GcWatch)
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
return function(kernel)
local Log = Logger.scope("Health")
local Gc = GcWatch.attach(kernel)
kernel.Scheduler:every(30, function()
local Stats = Gc:stats()
Log.info(`heap {math.round(Stats.HeapKb / 1024)}MB, alloc {math.round(Stats.AllocKbPerSec)}KB/s, {Stats.CyclesPerMin} cycles/min`)
if Stats.AllocKbPerSec > 4096 then
Log.warn("allocation rate over 4MB/s — GC assists will start eating frames")
end
end, kernel.Priority.Background)
end

Or skip the manual wiring entirely: kernel:enableDiagnostics() attaches a GcWatch for you (see Diagnostics integration).

Member Description
GcWatch.attach(kernel, options?) → watch Starts sampling on kernel.Scheduler:every(SampleSeconds, ..., Priority.Background). Reading gcinfo() is a counter read — effectively free.
watch:stats() → Stats Current snapshot (table below).
watch:destroy() Cancels the recurring sampler.

Options (all optional):

Field Default Description
SampleSeconds 0.1 Sampling interval.
WindowSeconds 5 Rate window — AllocKbPerSec and CyclesPerMin recompute and reset on this cadence.
Heap gcinfo Injectable heap reader, for specs.
Clock os.clock Injectable clock, for specs.
SkipLoop false Do not schedule the sampler; the caller drives _sample(now) manually (specs do this for determinism).

Stats fields:

Field Meaning
HeapKb Most recent heap sample.
PeakHeapKb High-water mark since attach.
AllocKbPerSec Allocation rate over the last closed window.
CyclesPerMin GC cycle cadence over the last closed window.
Cycles Total grow→shrink→grow cycles observed since attach.
LastReclaimKb KB freed by the most recent complete sweep run.
AvgReclaimKb ReclaimedKb / Cycles (0 before the first cycle).
ReclaimedKb Total KB reclaimed since attach.

kernel:enableDiagnostics(intervalSeconds?) attaches one GcWatch automatically (reusing the kernel’s existing one if present) and publishes three of its stats as ReplicatedStorage attributes every interval:

Attribute Source
ChloeKernelDiag{Role}GcAllocKbS AllocKbPerSec, rounded
ChloeKernelDiag{Role}GcCyclesMin CyclesPerMin, one decimal
ChloeKernelDiag{Role}GcReclaimKb LastReclaimKb, rounded

The debug panel reads these into graded GC rows in both the CLIENT and SERVER windows — its leak-vs-churn tooltips are this module’s diagnosis table made hoverable. kernel:shutdown() destroys the diagnostics-attached watch. See Kernel & Boot for the rest of the diagnostics surface.

GcWatch is fully spec-verified with an injected Clock and Heap reader and a SkipLoop double — the cycle bookkeeping and rate-window math above are asserted behavior, not documentation optimism.