Introduction
What are Signals?
A Signal is an implementation of the Observer Pattern. They're used to manage communication between different scripts, promoting loose coupling and readability, resulting in a more modular and maintainable codebase.
Roblox Signals vs. Web Signals
Not to be confused with modern web development terminology, Signals here refer to Event Emitters, not reactive state primitives!
Why use NamedSignal?
NamedSignal focuses on improving developer experience by enhancing type inference. Most notably, you can name your parameters (hence the name) and infer signatures from RBXScriptSignals!
Additionally, a comprehensive Deferred Mutations system helps prevent re-entrancy bugs by producing consistent and predictable behavior.
The API mirrors common standard and extended Signal APIs, with additional quality-of-life features like Connection:Reconnect() and resume-safe Signal:Wait(), so switching should be rather trivial.
Comparisons
See how NamedSignal compares to alternatives!
Work in Progress!
Performance for invocations has vastly improved since v2.3.0, these comparisons are currently outdated and pending updates.
vs. BindableEvents
The BindableEvent is the engine's implemention of the event emitter pattern, providing access to creating RBXScriptSignal and RBXScriptConnection objects.
However, as BindableEvents were introduced in February of 2012, it predates and lacks many modern amenities.
| Feature | NamedSignal | BindableEvent |
|---|---|---|
| Type-checking | ✅ Full support | ❌ No support |
Enhanced typing using User Defined Type Functions, featuring support for parameter naming and automatic inference for Signal.wrap. | Lacks any form of typechecking. Auto-fill provides no useful information without a wrapper. | |
| Execution Order | ✅ Predictable | ❌ Unintuitive |
| Intuitive order: First-In, First-Out. | Documented as "unpredictable", but Last-In, First-Out in practice.[1] | |
| Object Lifecycle | ✅ Simple | ❌ Messy |
Easy single-line creation with Signal.new().Simple clean-up with convenient methods, or just 'dereference and GC'. | Boilerplate-riddled Instance.new() creation for every event.Clean-up requires tracking every connection or destruction of the Instance. Forgetting to do so causes memory leaks. | |
| Argument Limitations | ✅ None | ❌ Deep-copy w/ information loss |
| Stays purely within Luau, arguments are passed directly to listeners without serialization. | Goes through the engine, serialization deep-copies values. Downstream changes have no effect, identities are different (equality comparisons don't work), non-normal indices are stringified or lost, and other limitations apply. | |
| Performance | ✅ Fast | ❌ Slow[2] |
| Efficient pure-Luau implementation with multiple optimizations. | Serialization and deep-copying for every listener is expensive and worsens rapidly with payload size, resulting in longer frametimes and higher memory usage. |
[1] BindableEvent execution order tested with the following code:
local event = Instance.new("BindableEvent")
for i = 1, 10 do
event.Event:Connect(function() print(i) end)
end
event:Fire()
--> prints 10 to 1 in reverse connection order.[2] Results and ratios vary extremely by use-case (connection count, payload, and other factors), as such no single "% faster" or "% slower" figure can be provided without being misleading.
If you want numbers anyway, in a 'reasonable' scenario: 5 connections, an array with 10 numbers as a payload, BindableEvents are roughly 2.5x slower than NamedSignal. This gap widens dramatically with larger payloads.
vs. GoodSignal/RbxUtil Signal
GoodSignal by stravant is the de facto standard of Roblox signal modules, with sleitnick's RbxUtil fork being an extension of it.
As sleitnick's fork is directly based on GoodSignal, just with some added methods and types, comparisons are made with RbxUtil's fork instead for simplicity.
| Feature | NamedSignal | RbxUtil Signal |
|---|---|---|
| Type-checking | Extended Capabilities | Standard, Generic Types |
Enhanced typing using User Defined Type Functions, featuring support for parameter naming and automatic inference for Signal.wrap. | Uses the standard generic type packs, which lacks the ability to define parameter names. | |
| Dead Coroutine Handling | ✅ Safely caught and handled | ⚠️ No coroutine.status check |
| This bug is safely caught and does not disrupt event dispatch. | Does not check the status of cached threads before reuse, in the unlikely event that a thread is killed while it's cached, it can remain stuck in the cache and prevent dispatching with the error "cannot spawn non-suspended coroutine". Some situations where this might occur include:
| |
| Performance[1] | Doubly-linked list structure | Singly-linked list structure |
| Disconnections are always constant time. A node can be efficiently removed as the previous node can be accessed without traversing the list to find it. | Disconnections are O(n) worst-case. To safely remove a node, the previous node must be updated to point to the next in line. But because the implementation uses a singly-linked list, it lacks a prev pointer, and has to traverse the list to find it. Older connections also take longer to disconnect as they are deeper in the list. | |
| L1/L2 Multi-thread Reuse | Single-thread Reuse | |
| Caches and reuses all created threads for enhanced efficiency at scale. Uses a two-layer cache, where one is a quick access upvalue that holds a single thread and is extremely quick to access, and the other is an array to hold multiple threads but is slightly slower to access. | Only reuses a single thread, storing it in an upvalue. Less efficient in situations where listeners yield which requires spawning threads each time. | |
| Reduced Resumption Overhead in Production | task.spawn Scheduler Overhead | |
In live games, uses coroutine.resume with error logging that produces similar info and tracebacks to normal errors, resulting in approximately 3x faster dispatch[2]. In Roblox Studio, Uses task.spawn for its jump-to-source debugging capability. | Always uses task.spawn, which goes through the task scheduler and incurs an overhead. |
[1] Focuses on architectural improvements rather than micro-optimizations. Additional performance details are available at Performance | NamedSignal.
[2]
Conservative 3x figure to account for some logging overhead, you may see up to 5x faster performance in raw comparison:
local iters = 50000
local thread = task.spawn(function()
while true do
coroutine.yield()
end
end)
local t1 = os.clock()
for _ = 1, iters do
task.spawn(thread)
end
local t2 = os.clock()
for _ = 1, iters do
coroutine.resume(thread)
end
local t3 = os.clock()
print(`coroutine.resume is {(t2 - t1) / (t3 - t2)}x faster than task.spawn`)vs. FastSignal/Signal+
FastSignal and Signal+ are quite similar signal libraries, with the main difference being performance.
| Feature | NamedSignal | FastSignal | Signal+ |
|---|---|---|---|
| Type-checking | Extended Capabilities | Standard, Generic Types | |
Enhanced typing using User Defined Type Functions, featuring support for parameter naming and automatic inference for Signal.wrap. | Uses the standard generic type packs, which lacks the ability to define parameter names. | ||
| Dead Coroutine Handling | ✅ Safely caught and handled | ⚠️ No coroutine.status check | |
| This bug is safely caught and does not disrupt event dispatch. | Does not check the status of cached threads before reuse[1], in the unlikely event that a thread is killed while it's cached, it can remain stuck in the cache and prevent dispatching with the error "cannot spawn non-suspended coroutine". Some situations where this might occur include:
| ||
| Performance[2] | L1/L2 Multi-thread Reuse | Single-thread Reuse | Array Multi-thread Reuse |
| Caches and reuses all created threads for enhanced efficiency at scale. Uses a two-layer cache, where one is a quick access upvalue that holds a single thread and is extremely quick to access, and the other is an array to hold multiple threads but is slightly slower to access. | Only reuses a single thread, storing it in an upvalue. Less efficient in situations where listeners yield which requires spawning threads each time. | Caches and reuses all created threads using an array. | |
| Reduced Resumption Overhead in Production | task.spawn Scheduler Overhead | ||
In live games, uses coroutine.resume with error logging that produces similar info and tracebacks to normal errors, resulting in approximately 3x faster dispatch[3]. In Roblox Studio, Uses task.spawn for its jump-to-source debugging capability. | Always uses task.spawn, which goes through the task scheduler and incurs an overhead. | ||
[1] Sources:
[2] Focuses on architectural improvements rather than micro-optimizations. Additional performance details are available at Performance | NamedSignal.
[3]
Conservative 3x figure to account for some logging overhead, you may see up to 5x faster performance in raw comparison:
local iters = 50000
local thread = task.spawn(function()
while true do
coroutine.yield()
end
end)
local t1 = os.clock()
for _ = 1, iters do
task.spawn(thread)
end
local t2 = os.clock()
for _ = 1, iters do
coroutine.resume(thread)
end
local t3 = os.clock()
print(`coroutine.resume is {(t2 - t1) / (t3 - t2)}x faster than task.spawn`)vs. LemonSignal
LemonSignal is a fairly competent Signal library, featuring the standard and extended signal API of most other libraries.
| Feature | NamedSignal | LemonSignal | |
|---|---|---|---|
| Type-checking | Extended Capabilities | Standard, Generic Types | |
Enhanced typing using User Defined Type Functions, featuring support for parameter naming and automatic inference for Signal.wrap. | Uses the standard generic type packs, which lacks the ability to define parameter names. | ||