RBXScriptSignal Internals
Signals are how Roblox does reactive programming: every event your script reacts to — touched, key pressed, player added, property changed — is an RBXScriptSignal. They look like a thin wrapper around "callback list", but there's real structure underneath: connection order, deferred firing, and a small handful of subtle behaviours that show up in production code.
The surface API
A signal has four public methods. They all return a RBXScriptConnection handle (except Wait, which yields the current coroutine).
signal:Connect(handler: (...any) -> ()): RBXScriptConnection
signal:Once(handler: (...any) -> ()): RBXScriptConnection
signal:Wait(): ...any
signal:ConnectParallel(handler: (...any) -> ()): RBXScriptConnectionConnect attaches a handler that fires every time the signal does, until disconnected. Once fires once and auto-disconnects. Wait yields the current coroutine until the next fire, returning whatever the signal passed. ConnectParallel is the parallel-Luau variant covered in our Actors article.
Connection ordering
Handlers are stored in a list, in the order they were connected. When the signal fires, they're visited in that same order. This sounds obvious, but it has a real consequence: the first handler can mutate state the later ones depend on.
local part = workspace.Part
part.Touched:Connect(function(hit) print("first →", hit.Name) end)
part.Touched:Connect(function(hit) print("second →", hit.Name) end)
-- A single touch logs:
-- first → HumanoidRootPart
-- second → HumanoidRootPartIf you need a specific order — say, "always run my handler last after every other listener has updated state" — there's no built-in priority system. The pragmatic workaround is to defer your handler with task.defer, which pushes it to after every synchronous handler on the current resumption.
Deferred vs immediate signals
Roblox has a per-place setting, Workspace.SignalBehavior, with three values:
- Immediate — firing the signal calls all handlers synchronously, on the stack of the code that triggered it.
- Deferred (the modern default) — firing the signal enqueues the handlers, and they run at the next scheduler flush after the current resumption finishes.
- Default — inherits from the engine's current default, which today maps to Deferred.
The difference shows up clearly in code like:
local part = workspace.Part
part.AncestryChanged:Connect(function(_, parent)
print("changed to", parent and parent.Name or "nil")
end)
part.Parent = nil
print("set parent to nil")
-- Deferred output:
-- set parent to nil
-- changed to nil
-- Immediate output:
-- changed to nil
-- set parent to nilDeferred is safer: nothing else runs in the middle of your current block, so you can't accidentally be observed mid-mutation. The price is one frame of latency on handler invocation, which is usually invisible.
Disconnect semantics
Calling :Disconnect() on a connection has three guarantees:
- The handler won't fire on signals raised after the disconnect.
- Re-disconnecting is a no-op (no error).
- If the signal is currently firing — i.e. handlers are being invoked — disconnecting won't affect handlers already queued in this fire, but it will skip later ones in the list.
That third point bites when you have a chain of handlers where one of them disconnects another. In deferred mode this rarely matters; in immediate mode it can produce surprising "why didn't this fire?" questions.
local conn1, conn2
conn1 = part.Touched:Connect(function()
print("first")
conn2:Disconnect()
end)
conn2 = part.Touched:Connect(function()
print("second") -- never runs after conn1 disconnects it
end)Once: the auto-disconnect helper
:Once is sugar for "connect, run the handler, then disconnect." Common shape — wait for a specific event to happen and only react once.
workspace.Loaded:Once(function()
print("workspace loaded")
end)RBXScriptConnectionInternally it's essentially local c; c = signal:Connect(function(...) c:Disconnect(); handler(...) end), which is what you'd write by hand otherwise.
Wait: yielding on a signal
:Wait() pauses the current coroutine and returns whatever the signal fires with. Useful for sequencing a script around external events.
local Players = game:GetService("Players")
print("waiting for a player...")
local player = Players.PlayerAdded:Wait()
print("player joined:", player.Name)Two things to know. :Wait() yields the coroutine, so it can't be called from contexts that don't allow yielding (the body of a non-yielding C callback, for example). And the return values are whatever the signal passes — if the signal fires with multiple arguments, they all come back from :Wait().
:Wait()ing on signal B inside that connection. That yields the connection mid-execution. In deferred mode this works but holds the deferred queue; in immediate mode it blocks every later handler on signal A's fire. Prefer a separate coroutine via task.spawn.Signal lifetime and garbage collection
A signal owned by an instance stays alive as long as the instance does. When the instance is destroyed, the signal and all its connections are released.
A custom signal (one your script created — usually with a third-party signal library, since Roblox doesn't expose a constructor for RBXScriptSignal itself) lives as long as any reference to it exists. If you create one in a module and never clear it, it lives forever along with all its connections — which is one of the most common leak patterns in long-running games.
Practical rule: every :Connect should be paired with a known disconnect path. Either it's on an instance whose destruction handles cleanup, or you store the connection and disconnect it on a cleanup event.
Custom signal implementations
Because Roblox doesn't expose a constructor forRBXScriptSignal, libraries that need script-defined signals implement their own. The canonical one is RbxUtil's Signal, but the implementation is short enough to write inline:
local Signal = {}
Signal.__index = Signal
function Signal.new()
return setmetatable({ handlers = {} }, Signal)
end
function Signal:Connect(fn)
local conn = { fn = fn, connected = true }
table.insert(self.handlers, conn)
function conn:Disconnect()
self.connected = false
end
return conn
end
function Signal:Fire(...)
for _, c in self.handlers do
if c.connected then task.spawn(c.fn, ...) end
end
endThe task.spawn is important: it isolates each handler so an error in one doesn't prevent the others from running. Real libraries add periodic cleanup to remove disconnected entries from the table, plus support for:Wait, :Once, and :Destroy.
Wrap-up
Signals are the most-used reactive primitive in Roblox, and most of the time you can treat them as ":Connect, run code, :Disconnect when done." The deeper behaviours — deferred vs immediate, ordering quirks, lifetime management — show up mostly when scripts grow beyond the simple cases. Knowing them in advance saves a lot of "why is this firing twice" debugging later.
Ather is the lead developer behind Atherhub. He's been writing Luau and Roblox tooling for the better part of a decade, with a focus on the messy interface between game-script internals and the platforms that host them. Have feedback on this article? Drop it in the Discord.