atherhubather.hub
Back to Guides
Roblox internals
9 min read
May 11, 2026

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.

Ather
Ather
Lead developer at Atherhub. Writes about Roblox internals, Luau, script engineering, and platform security.Last updated May 11, 2026

The surface API

A signal has four public methods. They all return a RBXScriptConnection handle (except Wait, which yields the current coroutine).

luau
signal:Connect(handler: (...any) -> ()): RBXScriptConnection
signal:Once(handler: (...any) -> ()): RBXScriptConnection
signal:Wait(): ...any
signal:ConnectParallel(handler: (...any) -> ()): RBXScriptConnection

Connect 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.

luau
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 → HumanoidRootPart

If 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:

luau
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 nil

Deferred 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.

luau
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.

luau
workspace.Loaded:Once(function()
    print("workspace loaded")
end)
ReturnsRBXScriptConnection
Returned for parity with Connect — you can :Disconnect() it before it fires if you change your mind, but otherwise it self-disconnects on first fire.

Internally 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.

luau
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().

Don't use Wait inside a Connect
A common bug: connecting to signal A, then :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:

luau
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
end

The 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
Written by Ather

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.