Memory management module for Roblox Luau.
Supersedes Maid, Trove, and Janitor with a far richer feature set drawn from systems languages, reactive programming, and modern resource management proposals.
Butler doesn't just copy Maid and Trove — it draws from battle-tested patterns across the software world:
| Source | Pattern Borrowed | Butler Feature |
|---|---|---|
| Rust | Drop trait — resources tied to scope lifetime |
butler:Guard(), LIFO cleanup order |
| Rust | defer! macro via scopeguard crate |
butler:Defer() |
| C++ | std::unique_ptr with custom deleter, SCOPE_EXIT |
butler:Guard(), butler:Defer() |
| RxJS | takeUntil operator |
butler:Until() |
| RxJS | CompositeDisposable / Subscription groups |
butler:Batch() |
| RxJS | finalize() teardown observer |
butler:OnClean() |
| TC39 | Explicit Resource Management (using / Symbol.dispose) |
butler:Wrap() |
| Go | defer statement |
butler:Defer() |
| Angular | DestroyRef lifecycle abstraction |
butler:LinkToInstance() |
| Problem | Butler Solution |
|---|---|
| No named/keyed task slots — you can't replace one task | :Set("name", obj) replaces and cleans the old one |
:Destroy() twice throws an error |
Double-destroy is a safe no-op |
| One bad task crashes the whole cleanup chain | All errors are caught, warned, and execution continues |
No :Once() that is also safe if the butler dies first |
:Once() auto-disconnects OR cleans if butler dies first |
| No sub-scoping / parent-child relationship | :Scope() creates a child butler with inherited lifetime |
Tweens: :Destroy() doesn't stop a playing animation |
:Tween() calls :Cancel() before :Destroy() |
| No way to replace a running animation/connection atomically | :Set("slot", newTween) cancels the old one first |
| No interval helpers — must wire RunService manually | :Every(interval, fn) tracked loop, auto-cancelled |
No thread management — manual task.spawn leaks |
:Task(), :Delay() — threads tracked and cancelled |
| No way to couple a resource to its custom teardown inline | :Guard(value, fn) — Rust-style Drop at acquisition site |
| No SCOPE_EXIT / defer for side effects | :Defer(fn) — Go-style deferred cleanup |
| No batch-add | :Batch({...}) — add many items at once |
| No teardown hooks | :OnClean(fn) — fires after every Clean/Destroy |
| No way to early-dispose one item while keeping tracking | :Wrap(obj) returns a disposable proxy |
| No fluent/builder chaining | :AddChain() returns self |
| No debug visibility into what's tracked | :Snapshot(), Butler.setDebug(true) |
Drop Butler.rbxm into ReplicatedStorage (or ServerScriptService) and require it:
local Butler = require(ReplicatedStorage.Butler)local Butler = require(ReplicatedStorage.Butler)
local RunService = game:GetService("RunService")
local butler = Butler.new()
-- Track a connection
butler:Connect(RunService.Heartbeat, function(dt) doWork(dt) end)
-- Named slot — auto-replaces on next call
butler:Set("character", character)
-- Child scope
local charScope = butler:Scope()
charScope:Add(someModel)
-- Auto-destroy when player leaves
butler:LinkToInstance(player)
-- Done:
butler:Destroy()Creates a new Butler.
Global toggle. When true, Butler prints each task as it is cleaned with elapsed time. Use during development to find leaks.
Tracks any object for cleanup. Returns the object for inline use.
| Type | Default Cleanup |
|---|---|
function |
object() |
RBXScriptConnection |
object:Disconnect() |
thread |
task.cancel(object) |
Instance |
object:Destroy() |
Table + Destroy |
object:Destroy() |
Table + destroy |
object:destroy() |
Table + Disconnect |
object:Disconnect() |
Tween (has Cancel+Play) |
object:Cancel() then object:Destroy() |
Promise (getStatus+cancel) |
object:cancel() if Running |
method provided |
object:<method>() |
local conn = butler:Add(signal:Connect(fn))
butler:Add(someInstance)
butler:Add(myTween, "Cancel")
butler:Add(function() print("cleaned!") end)Same as :Add() but returns self for builder chaining:
Butler.new()
:AddChain(conn1)
:AddChain(conn2)
:AddChain(part)
:LinkToInstance(player)Named slot. If the slot is already occupied, the old object is cleaned first.
butler:Set("healthBar", healthBarGui)
-- Later on respawn:
butler:Set("healthBar", newHealthBarGui) -- old one auto-destroyedRemove and clean a single task by name or reference:
butler:Remove("healthBar") -- by name
butler:Remove(someConnection) -- by referenceReturns true if a named slot is occupied.
Total number of currently tracked tasks.
Returns false after :Destroy() has been called.
Shorthand for butler:Add(signal:Connect(fn)).
One-shot connection. Disconnects after first fire. If the butler is destroyed first, the connection is still disconnected safely — no dangling callbacks.
RxJS takeUntil pattern. Connects fn to listenSignal, but permanently disconnects the moment untilSignal fires.
-- Update NPC AI on Heartbeat until it dies:
butler:Until(
npc.Humanoid.Died,
RunService.Heartbeat,
function(dt) updateNPC(npc, dt) end
)Connect multiple signal→fn pairs in one call:
butler:ConnectMany({
{ RunService.Heartbeat, onHeartbeat },
{ player.CharacterAdded, onCharacter },
})Create and immediately track an object:
local sig = butler:Construct(Signal) -- calls Signal.new()
local sig = butler:Construct(Signal.new) -- function constructor
local part = butler:Construct(Instance.new, "Part")Clone an instance and track the result.
Track an instance and optionally parent it in one call:
local part = butler:AddInstance(Instance.new("Part"), workspace)Spawn a tracked task.spawn thread. If the butler is destroyed, the thread is cancelled. Think of it as a Rust scoped thread — its lifetime is bounded by the butler's.
butler:Task(function()
while true do
doWork()
task.wait(0.05)
end
end)Schedule a call after t seconds. Thread is tracked and cancellable:
butler:Delay(5, function()
print("5 seconds later — or never if butler died")
end)Run fn on a fixed interval using a tracked loop. No RunService connection needed:
butler:Every(1/20, function()
updateMinimap()
end)Rust Drop trait / C++ ScopeGuard / unique_ptr with custom deleter.
Couples a resource to its cleanup function at the acquisition site — the core RAII principle. The cleanup function receives the value as its argument.
-- Acquisition and teardown are co-located and explicit:
local lock = butler:Guard(acquireLock(), function(l) l:release() end)
local sound = butler:Guard(Sound:Play(), function(s) s:Stop() end)
local file = butler:Guard(openFile(path), function(f) f:close() end)Unlike :Add(obj, "Method"), Guard accepts arbitrary teardown logic and makes the relationship between resource and destructor unambiguous.
C++ SCOPE_EXIT / Go defer / Rust defer! macro.
Queues a side-effect to run at the next Clean() or Destroy(). Explicitly communicates "this is a cleanup action, not a tracked resource":
butler:Defer(function()
Analytics:recordSessionEnd(player)
print("Scope exited.")
end)Returns self for chaining.
RxJS CompositeDisposable pattern. Add multiple items at once:
butler:Batch({
conn1,
conn2,
{ myTween, "Cancel" }, -- {object, method} pair
{ myAudio, "Stop" },
})RxJS finalize() operator. Registers a teardown observer that fires after every Clean() or Destroy():
butler:OnClean(function()
Metrics:recordCleanup(player)
print("Cleanup complete!")
end)Multiple observers can be registered; all are called in order.
TC39 Explicit Resource Management / Symbol.dispose / JavaScript using keyword.
Returns a proxy that forwards all method calls to the wrapped object, but adds a :Dispose() method for early removal from the butler:
-- JavaScript equivalent: `using stream = openStream();`
local handle = butler:Wrap(openStream())
handle:Read(1024) -- proxied to stream:Read(1024)
handle:Dispose() -- removes stream from butler, cleans it early
-- If :Dispose() is never called, butler:Destroy() cleans it normallyCreate, play, and track a Tween. On cleanup, :Cancel() is called before :Destroy() — stopping any mid-play animation. Maid, Trove, and Janitor all miss this and only call :Destroy(), which doesn't stop a playing tween:
local tween = butler:Tween(
part,
TweenInfo.new(0.5, Enum.EasingStyle.Quad),
{ CFrame = targetCFrame }
)
-- part smoothly moves. If butler:Destroy() fires, the part stops immediately.A tracked WaitForChild wrapper. If the butler is destroyed while waiting, the coroutine is cancelled — no orphaned yields:
local gui = butler:WaitFor(player.PlayerGui, "MainGui", 10)
if gui then
setupGui(gui)
endCreate a child butler whose lifetime is tied to the parent. Inspired by Rust's std::thread::scope:
local playerButler = Butler.new()
local function onCharacterAdded(char)
-- Create a new scope each respawn
local charScope = playerButler:Scope()
charScope:Add(char)
charScope:Once(char.Humanoid.Died, function()
print("died")
end)
end
player.CharacterAdded:Connect(onCharacterAdded)
-- When playerButler:Destroy() is called, all charScopes are also destroyedAuto-destroy the butler when a Roblox instance is destroyed:
butler:LinkToInstance(player) -- Destroy() on player leave
butler:LinkToInstance(character, true) -- Clean() only (reuse the butler)Clean all tasks and fire OnClean observers, but keep the butler alive for reuse. Cleanup order: named tasks → anonymous tasks (LIFO) → OnClean observers.
Clean and permanently invalidate. Calling :Destroy() a second time is a safe no-op.
Returns a table of all tracked tasks keyed by their index or name. Use during development to inspect what's currently tracked:
local snap = butler:Snapshot()
for k, v in pairs(snap) do
print(k, "→", v)
end
-- 1 → RBXScriptConnection
-- 2 → thread
-- healthConn → RBXScriptConnection
-- character → Instancelocal Butler = require(ReplicatedStorage.Butler)
local playerButlers: { [Player]: typeof(Butler.new()) } = {}
game.Players.PlayerAdded:Connect(function(player)
local butler = Butler.new()
playerButlers[player] = butler
butler:LinkToInstance(player) -- auto-destroys on leave
butler:OnClean(function()
print(player.Name, "left, all cleaned up")
end)
butler:Connect(player.CharacterAdded, function(char)
local scope = butler:Scope()
scope:Add(char)
scope:Until(
char.Humanoid.Died,
game:GetService("RunService").Heartbeat,
function(dt) updateCharacter(char, dt) end
)
end)
end)local MySystem = {}
MySystem.__index = MySystem
function MySystem.new(player)
local self = setmetatable({}, MySystem)
self._butler = Butler.new()
self._butler:LinkToInstance(player)
self._butler:OnClean(function()
print("MySystem cleaned up")
end)
return self
end
function MySystem:Destroy()
self._butler:Destroy()
end-- Cleanly swap animations using named slots + Tween cleanup
local function playAnimation(butler, track, info, goals)
-- Old tween is Cancel()'d and Destroy()'d before new one plays
butler:Set("activeTween", butler:Tween(character.HumanoidRootPart, info, goals))
endButler.setDebug(true)
local butler = Butler.new()
butler:Defer(function()
print("Total tasks cleaned:", taskCount)
end)
Butler.setDebug(false) -- turn off for productionbutler:Batch({
RunService.Heartbeat:Connect(onHeartbeat),
RunService.RenderStepped:Connect(onRender),
{ myTween, "Cancel" },
{ mySound, "Stop" },
})Butler cleans anonymous tasks in Last In, First Out order, mirroring how Rust drops local variables and how C++ destroys stack objects. This means the most recently added resource is cleaned first, which is the safest default:
butler:Add(A) -- cleaned 3rd
butler:Add(B) -- cleaned 2nd
butler:Add(C) -- cleaned 1st ← last added, first cleanedNamed tasks are cleaned before anonymous tasks, in arbitrary order.
Butler v2.0.1