Skip to content

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.

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 of Idle keyed 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:

  1. If IdleSet[scratch] is set, the table is already idle and this is a double release. It raises table was already released to the pool with error(message, 2), so the reported line is the caller’s.
  2. table.clear(scratch) removes every key and keeps the capacity.
  3. 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:

src/Server/Game/Services/CampfireService.luau
--!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 = 12
local HealPerTick = 0.5 -- 5 health per second at 10 ticks
local CampfireService = {
Name = "CampfireService",
}
function CampfireService.init(self: any, kernel: any)
self.Kernel = kernel
end
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()
end
end
return CampfireService

Acquire right before the fill and release right after the last read. Never keep Nearby past the release call.

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.
  • 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 forEach snapshots 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:radiusInto before 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.

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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.