GenericSignal | Somewhat Fast Signal Implementation

GenericSignal

Stop using BindableEvents, nobody likes them


CreatorMarketplaceButton   DownloadButton


GenericSignal is a lightweight signal implementation designed for quick runtimes and user-friendliness. GenericSignal objects have the same capabilities as RBXScriptSignals, but they’re much more performant and include a wide range of utility methods to handle additional, niche use-cases.


RBXScriptSignal is Luau’s built-in signal class, used to manage events like Instance.Changed and Player.CharacterAdded, so it’s likely you’ve used one before, even if you didn’t realize it. In reality, RBXScriptSignals are one of the most frequently used features in all of Luau, yet they’re unoptimized and can’t be created manually. Because of this, most developers resort to BindableEvents, which are even less performant.

There are plenty of wrapper classes that address this issue, such as GoodSignal and FastSignal. However, GenericSignal differs from these other modules with its multiple-thread caching system, which reuses coroutines, significantly improving firing performance. GenericSignal objects are lightweight by nature and consequently use very little memory. This, combined with the implementation of the thread cache, gives you much more flexibility when working with multiple signal objects.

GenericSignal objects also store their connections using linked lists, which is optimal for signal implementations because of their frequent insertions, deletions, and shifts. There are some places where GenericSignal performs worse than other modules, but it is considerably more well-rounded and includes plenty of extra features that make up for its shortcomings.


Immediate & Deferred Firing:

The two primary methods of firing a signal are immediate and deferred. Immediate firing (Fire()) executes connections as quickly as possible using task.spawn(), while deferred firing (FireDeferred()) schedules connections to run at the end of the current resumption cycle (always the same frame) using task.defer(). Neither method halts the execution of the current thread.

GenericSignal provides an option for both methods, but using immediate firing is more efficient because it utilizes the thread cache. However, Roblox is gradually shifting towards using only deferred firing, so it’s not a bad idea to do that yourself, especially if the performance impacts of the switch are minimal.

Global Signals & Functions:

You can register GenericSignal objects globally within the local environment by calling GenericSignal:MakeGlobal(name), enabling you to access them anywhere via GenericSignal.globalSignals[name]. There are also a few functions that utilize this system to perform operations on all global signals.

Utility Methods:

GenericSignal objects have all the same functionality as an RBXScriptSignal, but they also include a few new methods to add some extra functionality. This includes methods for memory management, such as Clear() and Destroy(), and some QOL methods, such as AutoDisconnect() and Clone().

QOL Features:

GenericSignal also includes various quality-of-life features that make using the module much more convenient:

  • Method chaining support
  • Comprehensive type checking
  • Error handling for specific methods
  • Metamethods for __call(), __iter(), and __len()

Creating GenericSignal Objects

Creating GenericSignal Objects:

Creating a GenericSignal object only takes nineteen characters: GenericSignal.new(). The constructor doesn’t take any arguments, so all you have to do is call it.

local GenericSignal = require(scriptLocation.GenericSignal)

local exampleSignal = GenericSignal.new()

API:

  • new: () -> GenericSignal

Managing GenericSignal Objects

Creating GenericConnection Objects:

The two methods used to create GenericConnection objects are Connect() and Once(), each accepting a function as an argument. The function received is used to create a connection, which is subsequently linked to the signal that the method was called on.

Connect() creates a connection and links it to the signal object. Once() is similar, but the connection it creates will automatically disconnect itself after the signal fires once.

exampleSignal:Connect(function(...)
    print("signal fired")
end)
-- called every time `exampleSignal` is fired

exampleSignal:Once(function(...)
    print("first signal fire")
end)
-- called only the next time `exampleSignal` is fired, then automatically disconnected

Firing GenericSignal Objects:

Both Fire() and FireDeferred() accept any number of arguments of any type. These arguments are passed to all of the signal’s connections and returned to any yielding Wait() calls.

exampleSignal:Fire(args)
-- executes connections as quickly as possible using the thread cache

exampleSignal:FireDeferred(args)
-- schedules connections to run at the end of the current resumption cycle

Managing GenericConnection Objects:

Both Connect() and Once() second return value will be the connection they created, allowing you to manage individual connections.

GenericConnection objects currently have two methods other than Destroy(): Disconnect() and AutoDisconnect(). Both remove the connection from the signal’s list, but Disconnect() runs immediately when called, while AutoDisconnect() accepts another RBXScriptSignal or GenericSignal and only runs when that event fires.

local signal, connection = exampleSignal:Connect(function()
 print("connected")
end)

connection:Disconnect()
-- disconnects the connection immediately

connection:AutoDisconnect(game.Players.PlayerRemoving)
-- disconnects the connection when the `PlayerRemoving` event fires

API:

  • Connect: (self: GenericSignal, func: (...any) -> ()) -> (GenericSignal, GenericConnection)
  • Once: (self: GenericSignal, func: (...any) -> ()) -> (GenericSignal, GenericConnection)
  • Fire: (self: GenericSignal, ...any) -> GenericSignal
  • FireDeferred: (self: GenericSignal, ...any) -> GenericSignal
  • AutoDisconnect: (self: GenericConnection, disconnectEvent: RBXScriptSignal | GenericSignal) -> ()
  • Disconnect: (self: GenericConnection) -> ()

Managing Global GenericSignal Objects

Registering Global Signals:

You can use MakeGlobal() to register a signal globally within the local environment, and MakeLocal() to unregister it. Only MakeGlobal() takes an argument: the string name, which is used to access the signal via GenericSignal.globalSignals[name].

exampleSignal:MakeGlobal("exampleSignal")
-- adds the signal to the `globalSignals` table (access via GenericSignal.globalSignals["exampleSignal"])

exampleSignal:MakeGlobal("otherName")
-- overrides the current global name

exampleSignal:MakeLocal()
-- removes the signal from the `globalSignals` table

Using Batch Operations on Global Signals:

The two functions for batch operations are fireAll() and destroyAll(). Neither takes any arguments, and they do exactly what it sounds like they do.

GenericSignal.fireAll()
-- fires every global signal

GenericSignal.destroyAll()
-- destroys every global signal

Waiting on Multiple Signals:

The other two functions for global signals are waitAny() and waitAll(). waitAny() is just like Wait(), but it yields until any one of the provided signals fires, then returns that signal’s arguments. waitAll() is similar, but instead it waits for all of the provided signals to fire and returns nothing.

local args = GenericSignal.waitAny()
-- wait for any global signal

local customSignals = {
 signal1 = exampleSignal1,
 signal2 = exampleSignal2
}
GenericSignal.waitAll(customSignals)
-- wait for specific signals

API:

  • MakeGlobal: (self: GenericSignal, globalName: string) -> GenericSignal
  • MakeLocal: (self: GenericSignal) -> GenericSignal
  • fireAll: () -> ()
  • destroyAll: () -> ()
  • waitAll: (signals: {[string]: GenericSignal}?) -> ()
  • waitAny: (signals: {[string]: GenericSignal}?) -> ...any
  • signals provides a specific set of GenericSignal objects for the function to use, but is not required and will default to the globalSignals table

Other Operations with GenericSignal Objects

Waiting on GenericSignal Objects:

The Wait() method yields the current thread until the signal object it was called on is fired, then returns the arguments that the signal was fired with.

local args = exampleSignal:Wait()
-- yields until `exampleSignal` fires, then returns its arguments

FUN FACT: coroutine.yield() returns the arguments of whatever task.spawn() call resumes the coroutine.


Cloning GenericSignal Objects:

Cloning is simple; all you do is call the method Clone(). This creates and returns a shallow copy of the GenericSignal object using the same GenericConnection objects.

local clonedSignal = exampleSignal:Clone()
-- returns a shallow copy of `exampleSignal`

Clearing & Destroying GenericSignal Objects:

Clear() removes all connections from the signal but keeps the signal object intact. Destroy() removes all connections, removes the signal from globalSignals if applicable, and cleans up the OOP table. Neither method accepts any arguments.

exampleSignal:Clear()
-- removes all connections

exampleSignal:Destroy()
-- fully destroys the signal and cleans up memory

API:

  • Wait: (self: GenericSignal) -> ...any
  • Clone: (self: GenericSignal) -> GenericSignal
  • Clear: (self: GenericSignal) -> GenericSignal
  • Destroy: (self: GenericSignal) -> ()


Changelog:

Version 1.0 (release)

Initial release of the ModuleScript

Version Date: 10/11/25
Version Store Page: GenericSignal 1.0
Version File: GenericSignal Source


Most information on the ModuleScript can be found in this post, but if you have any questions, feel free to leave a reply, and I will usually respond within a day or two. Feedback and suggestions are also appreciated, as there is a lot more I plan to do with this module.

2 Likes

It’s uh… Pretty lie.. Linked Lists are ineffective (or sometimes same thing as arrays) for dynamic language like LuaU.


Obviously, the Fire will be way slower because it has to do a lot of index overhead.
So yeah, your module is not pretty much fast. I’d rather say it’s the exact same as FastSignal, but has better thread pool.

(btw this could be simplified to GenericSignal.__call = GenericSignal..Fire)
image

2 Likes

Good to know, I’m not too educated on all the specifics with Luau’s inner workings, but for this I was just going off of what the other popular signal implementations say:

Based off what you said, I’m thinking I just misunderstood why the linked list is beneficial, so thanks for pointing that out to me. Also, yes the main difference is the thread pool. This module’s runtimes are almost exactly the same or slower than FastSignal’s most of the time, which is why I mentioned that it performs worse in some places. I never explicitly stated it was actually faster than other modules.

Linked lists are good, but in more memory-control languages (like C, C++ and etc.), mainly because it has infinite size and can have fragmented pointers in memory (so it won’t intersect other object’s memory addresses)

But tables in LuaU are just a hashmap (or smth like that), which act just like LinkedList. So making LinkedList in LuaU is just making a LinkedList of LinkedLists, or reinventing the wheel.

I was talking about speed because the title says it’s “Lighting-Fast” though.

1 Like

Well the title is more just to get clicks, but I’ll probably change it since that is pretty misleading. I mean the module is still pretty fast, but I guess not “lightning” fast. I’ll have to look into link lists some more too, they’re just really confusing overall.

1 Like

Lua(u) dictionaries aren’t linked lists. They’re hash tables that use linear probing for collisions. Some older hashmap implementations used linked lists for handling collisions, but in the age of local memory caching, no one does that anymore. Linked lists are actually terrible in low-level languages because they fracture cache locality.

Linked lists are good here because cache locality isn’t as salient in an interpreted language and they preserve ordering like an array while also allowing fast insert/removal of elements like a dictionary. Their only downside is that they iterate slowly, but the cost is negligible if you’re aiming for correctness here.

3 Likes

Yeah, but hash tables act like LinkedLists.

The array-like dictionary table offers the same things you’ve said (fast insert and removal), but it doesn’t make a huge amount of tables. Since there’s absolutely no need for array ordering, we don’t give it as benefit.

It is, in fact, not negligible. Methods like Connect and Fire are more frequently used and you should optimize them. And since arrays provide very quick iteration (in comparison to LinkedList), you’ll more likely to use them. The correctness will be literally same.

Hash tables do not act like linked lists. They are definitively different data structures, and you have a severe misunderstanding if you think they are at all similar here.

If you used a dictionary to store connections like stravant’s FastSignal does, the order in which connections are fired would be undefined. This is clearly incorrect behavior when compared to RBXScriptSignal’s (immediate) expected firing order, which is why dictionaries cannot be used here.

Using an array would preserve ordering and also yield faster iteration times, but it would come at the cost of making disconnects O(n^2) due to both searching and shifting the other connections to prevent gaps. Linked lists, on the other hand, only have O(n) complexity for both disconnects and firings.

You can make the argument that disconnects happen so rarely in your codebase that the tradeoff is worth it, but that is an entirely circumstantial happenstance (especially with :Once and :Wait), which a general implementation cannot assume is always true. A linked list is empirically the best option here.

Uhm, no. Disconnects can be somewhat like O(1). If you store an index on which the callback registered, you’d be able to use table.remove with just deleting last element and then checking if the disconnected callback is the same as deleted and if it’s not equal then swap indexes and positions in callback’s array. There will be no any global difference in what order the callbacks are fired.

How would you preserve order like this? If you are switching the last element to get O(1) disconnects, then that wouldn’t preserve the order, at least not if you are iterating over the array normally. Are you saying to store the index of connections separately from the main array?

By “there will be no any global difference in what order the callbacks are fired” I meant that there’ll be no difference if the function will be fired after some function nor before the said function. I personally don’t see any cases where the order do really matter. If you do see them, inform me please.

Oh, ok that makes sense. I can’t think of any off the top of my head, but I think that including that functionality might be beneficial in some cases. Better to make the module more flexible in my opinion.