TablePool
Hot loops that build a scratch list every tick — spatial query results, target-scan candidates, per-projectile hit sets — pay for a table allocation and its eventual collection on every single pass, even though the table’s shape barely changes tick to tick. TablePool recycles those tables instead: acquire() hands back an empty table (its previously-grown array part still reserved), the caller fills it, and release(t) clears it and returns it to an idle stack for the next tick to reuse. This is the table-shaped sibling of Pool, which does the same idle-stack trick for Instances — Pool stops Instance.new/Destroy churn, TablePool stops {} churn. Different resource, same idea: stop paying an allocation cost that recurs every frame.
Mental model
Section titled “Mental model”TablePool is two module-level structures, no .new() — there’s exactly one shared pool for the whole game:
Idle— an array used as a stack of recycled tables.IdleSet— a set mirror ofIdle(keyed by table identity), so checking “is this already idle” is an O(1) lookup instead of a scan.
acquire() pops the top of Idle and clears its IdleSet entry; if the stack is empty it allocates a fresh {}. Either way the caller gets an empty table back — but a table popped off Idle already has whatever array capacity it earned last time it was filled. Luau (like all Lua) grows a table’s internal array part in doubling steps and never shrinks it on table.clear; a table that once held 200 entries keeps room for 200 without reallocating, forever, as long as it keeps getting recycled instead of dropped.
release(scratch) does three things in order:
- Checks
IdleSet[scratch]— if the table is already sitting in the idle stack, that’s a double release, and it fails loud (see below) instead of corrupting the stack. table.clear(scratch)— wipes every key, keeping the reserved capacity.- Pushes onto
Idleand marksIdleSet[scratch] = true, but only if#Idle < MaxIdle(256). Past the cap, the cleared table is simply dropped — no error, no growth, it just becomes normal GC-collected garbage. The idle stack cannot outgrow 256 tables no matter how many callers release into it.
Note what release does not check: unlike Pool, which tracks a LiveSet of instances it vended and errors when you release something it never handed out, TablePool has no liveness tracking at all. IdleSet only catches a table that’s already idle — releasing an arbitrary table you built by hand (never acquired from the pool) is not an error, and it will get adopted into the idle stack. The pool trusts you to only release what you acquired.
-- src/Server/Bootstrap.luau (works identically on the client)local ReplicatedStorage = game:GetService("ReplicatedStorage")local TablePool = require(ReplicatedStorage.ChloeKernel.TablePool)local Spatial = require(ReplicatedStorage.ChloeKernel.Spatial)
return function(kernel) local Index = Spatial.new({ CellSize = 8 })
kernel.Bus:subscribe("Character.Spawned", function(_, id, position) Index:set(id, position) end)
game:GetService("RunService").Heartbeat:Connect(function() local Nearby = TablePool.acquire()
-- radiusInto appends into caller scratch instead of allocating a result table Index:radiusInto(workspace:GetPivot().Position, 24, Nearby)
for _, id in Nearby do -- per-tick work against each nearby id end
TablePool.release(Nearby) end)endThe shape to copy: acquire right before the fill, release right after the last read. Never hold Nearby past the release call — see Gotchas.
API reference
Section titled “API reference”| Member | Description |
|---|---|
TablePool.acquire() → { [any]: any } |
Pops the most recently released table off the idle stack (empty, capacity intact), or allocates a fresh {} when the stack is empty. |
TablePool.release(scratch) |
Clears scratch with table.clear and pushes it onto the idle stack if under MaxIdle. Throws on a double release. |
TablePool.idleCount() → number |
Number of tables currently sitting idle (#Idle). |
Config
Section titled “Config”| Constant | Value | Behavior |
|---|---|---|
MaxIdle |
256 |
Cap on the idle stack. release past this count still clears the table but does not push it — it’s dropped for the collector, and the idle stack never exceeds 256 tables. |
Failure mode
Section titled “Failure mode”A double release — calling release twice on the same table without an acquire in between — throws:
table was already released to the poolThrown via error(message, 2), so the reported line is the caller’s release call, not the line inside TablePool.release — the error points at the bug, not the library.
Who uses pooled scratch internally
Section titled “Who uses pooled scratch internally”- Spatial —
radiusInto/boxInto/coneIntoappend query results into a caller-provided table instead of returning a fresh one each call; every per-tick spatial query in the framework acquires scratch from TablePool, queries into it, and releases it. See Spatial. - NPCKit — target-scan prefilters broad-phase candidates through a stale-padded radius query before any sight test, filling pooled scratch each scan instead of allocating a candidate array per NPC per tick. See NPC Kit.
- Projectiles — per-projectile candidate sets (the entities a projectile’s hit check needs to consider that tick) are built into pooled scratch rather than a fresh table per projectile per frame. See Projectiles.
All three are hot, per-tick, per-entity loops — exactly the profile where a table allocation that would otherwise be thrown away 30–60 times a second is worth recycling instead.
Design notes
Section titled “Design notes”| Reach for… | When |
|---|---|
A plain {} literal |
One-off or low-frequency tables — event handlers, setup code, anything that doesn’t run every tick. GC pressure from occasional allocation is not a problem worth solving. |
TablePool |
A scratch table that gets filled and discarded on a hot per-tick or per-entity loop, especially one that tends to grow to a stable size (query results, candidate sets) — recycling the array capacity is the actual win, not just avoiding the allocation. |
| Pool | You’re recycling Instances (Parts, VFX, sounds), not Lua tables. Different lifetime model entirely — Pool unparents on release and reparents on acquire; TablePool only ever manipulates a bare table. |