TablePool
Recycles cleared Lua tables with their array capacity intact, so per-tick scratch lists stop feeding the garbage collector.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .TablePool - Since
- v0.6.0
- Source
- TablePool.luau
Loops that build a scratch list every tick (spatial query results, target candidates, a snapshot of live projectiles)
pay for a table allocation and its collection on every pass, although the list’s size barely changes from tick to tick.
TablePool recycles those tables. acquire() returns an empty table, often one whose array part already grew,
release(t) clears it and puts it back. Pool does the same for Instances. The usual partner is
Spatial, whose Into queries append into a table you pass instead of allocating a result.
Mental model
Section titled “Mental model”TablePool has no constructor. It is two module-level structures, so every script in one VM shares one pool (one on the server, one per client):
Idle, an array used as a stack of cleared tables.IdleSet, a set mirror ofIdlekeyed by table identity, so “is this table already idle” is one lookup.
acquire() pops the top of Idle, or allocates {} when the stack is empty. A recycled table comes back with the
array capacity it grew to last time: Luau grows a table’s array part in doubling steps and table.clear keeps that
allocation, so a table that once held 200 entries holds 200 again without reallocating.
release(scratch) does three things in order:
- If
IdleSet[scratch]is set, the table is already idle and this is a double release. It raisestable was already released to the poolwitherror(message, 2), so the reported line is the caller’s. table.clear(scratch)removes every key and keeps the capacity.- If fewer than 256 tables (
MaxIdle) are idle, the table goes on the stack. Otherwise it is dropped and the collector takes it. The idle stack never holds more than 256 tables.
release tracks nothing about liveness. Unlike Pool, which refuses instances it never vended,
TablePool accepts any table: releasing one you built yourself adopts it into the pool. IdleSet only catches a table
that is already idle.
A campfire that heals nearby players ten times a second, with no per-tick allocation:
--!strict-- CampfireService: heals players standing near the campfire.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)local Spatial = require(ReplicatedStorage.ChloeKernel.Spatial)local TablePool = require(ReplicatedStorage.ChloeKernel.TablePool)
local Log = Logger.scope("CampfireService")
local HealRadius = 12local HealPerTick = 0.5 -- 5 health per second at 10 ticks
local CampfireService = { Name = "CampfireService",}
function CampfireService.init(self: any, kernel: any) self.Kernel = kernelend
function CampfireService.start(self: any) local Fire = workspace:FindFirstChild("Campfire") if not (Fire and Fire:IsA("BasePart")) then Log.warn("expected a BasePart at Workspace.Campfire") return end self.Characters = Spatial.characters(self.Kernel) -- ids are Player instances self.Tick = self.Kernel.Scheduler:every(0.1, function() local Nearby = TablePool.acquire() self.Characters:radiusInto(Fire.Position, HealRadius, Nearby) -- appends; allocates no result table for _, Player in Nearby do local Character = Player.Character local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid") if Humanoid and Humanoid.Health > 0 then Humanoid.Health = math.min(Humanoid.Health + HealPerTick, Humanoid.MaxHealth) end end TablePool.release(Nearby) -- after the last read: the next acquire may hand it to another caller end)end
function CampfireService.stop(self: any) if self.Tick then self.Tick:cancel() end if self.Characters then self.Characters:destroy() endend
return CampfireServiceAcquire right before the fill and release right after the last read. Never keep Nearby past the release call.
API reference
Section titled “API reference”| Member | Description |
|---|---|
TablePool.acquire() -> { [any]: any } |
Pops the most recently released table (empty, capacity kept), or returns a new {} when none is idle. |
TablePool.release(scratch) |
Raises on a double release, clears scratch with table.clear, and pushes it onto the idle stack when fewer than 256 are idle. |
TablePool.idleCount() -> number |
Tables currently idle. |
| Constant | Value | Behavior |
|---|---|---|
MaxIdle |
256 |
Cap on the idle stack. A release past the cap still clears the table and then drops it for the collector. |
Who uses pooled scratch
Section titled “Who uses pooled scratch”- Projectiles on the server: each step snapshots the live shots into two pooled tables before
it walks them (hit callbacks can fire new shots mid-step), and
forEachsnapshots serials the same way. With a Spatial index attached, each hitbox shot also gathers its nearby candidates and target list into pooled scratch. - NPC Kit: with a Spatial index configured, each target scan fills pooled scratch through
Spatial:radiusIntobefore any sight test, instead of allocating a candidate array per NPC per scan.
Spatial itself does not use TablePool; it provides the Into variants (radiusInto, boxInto,
coneInto) that make pooled scratch useful. Since 0.8.0 its radius, box and cone filters are module-level functions
that read the query from module slots, so a query into pooled scratch allocates neither a result table nor a closure.
Design notes
Section titled “Design notes”| Reach for | When |
|---|---|
A {} literal |
One-off or infrequent tables: event handlers, setup code, anything that does not run every tick. |
| TablePool | A scratch table filled and discarded in a per-tick or per-entity loop, especially one that settles at a stable size. Keeping the grown capacity is most of the win. |
| Pool | You recycle Instances. Pool unparents on release; TablePool only clears a table. |