Stop Using BindableEvents: A Guide to Signals in Luau

This article explains why you should be using signals in your code as well as the benefits signals can provide compared to alternatives like BindableEvents. Note: This article was touched up with an AI content writer, however, everything here has been looked over the ensure that all the information here is up-to-date and accurate.

This article is an more in-depth explanation for a reply I posted a few months back:


With that being said, lets get started!


If you’re building anything remotely complex on Roblox, you’ve already hit the wall. You know the one. Your Inventory system needs to talk to your UI, which then needs to update the DataStore, and somehow, everything ends up broken.

How are you handling that right now?

If your answer involves a spaghetti bowl of callbacks, while wait() do loops, or—God forbid—a pile of BindableEvents sitting in a Folder in the Workspace, stop. Just stop. You need Signals.

We’re going to break down what a Signal class actually is, why BindableEvents are secretly sabotaging your architecture, and how to stop writing code that looks like it was pasted together by a tired intern.

What the heck is a Signal?

Simply put, a signal just stores a list of callbacks and iterates through those callbacks when the signal is fired, forwarding the passed in parameters.

It’s a implementation of the Observer Pattern (I highly recommend checking this website out, it has great information).

But let’s skip the CS degree jargon. Think of it exactly like a built-in Roblox event, like Part.Touched or Player.PlayerAdded. The difference is that you aren’t relying on some physical Instance created by the engine. You’re building the event purely in Luau code.

The “Radio Station” Analogy

Picture your game logic as a Radio Station.

The Signal: The frequency.
The Firing: The DJ blasting a track.
The Connection: The listeners in their cars.

The DJ (your script) doesn’t care who is listening. It just broadcasts. If zero people are listening? Fine. If a thousand are listening? Also fine. That’s the beauty of Event-Driven Architecture.

Why BindableEvents are actually terrible

“But BindableEvents work fine!”

Do they, though? I see beginners lean on them constantly, but they come with three massive architectural headaches that will bite you later.

1. Parameter Mangling

This is the big one. When you fire a BindableEvent, Roblox copies your data and send it to your each of your active connections.

This process deep-copies tables.

If you pass a table and modify it in the receiving script, the original table doesn’t change. This is because, internally, Roblox passes a copy instead of a reference to the original table. If your system relies on a metatable being passed through you’ll notice it gets stripped as you’re passing by copy and not reference.

2. Instance Clutter

You have to create physical folders of instances in ReplicatedStorage just to handle logic? That’s messy. It’s 2025 (or later, depending on when you read this). We shouldn’t be using physical objects to handle abstract logic.

You might be thinking to yourself, “Why not just parent a bindable event to nil and store a reference to it?”. Yeah that works! but you’ll still be missing out on all the other benefits a custom signal implementation can provide.

3. Performance

Creating and destroying Instances is expensive. Allocating a lightweight Lua table (which is all a Signal is) is wicked fast.

The bottom line: Signals let you pass complex data types—metatables, circular references, whatever—without Roblox stripping the data or breaking the memory address.

How to actually use a Signal

There are plenty of solid open-source options out there (stravant’s GoodSignal is my #1 recommendation).

Here is what it looks like in practice. Let’s clean up a messy scenario.

The Scenario: A Player Levels Up

This is a general usage scenario for both signals and BindableEvents.

The “Old” Way (Hard Dependencies):

You try to require the UI script directly inside the Level script. Now they are married. If you delete the UI, the Level script throws an error. It’s fragile.

The “Signal” Way (Decoupled):

-- In your 'LevelSystem' Module
local Signal = require(path.to.Signal)

local LevelSystem = {}
LevelSystem.OnLevelUp = Signal.new() -- Create the signal

function LevelSystem:AddXP(amount)
    self.CurrentXP += amount
    if self.CurrentXP >= self.MaxXP then
        self.Level += 1
        -- Fire the signal! We don't care WHO is listening.
        self.OnLevelUp:Fire(self.Level) 
    end
end

return LevelSystem

Now, the rest of your game can just tune in:

-- In a UI Script
local LevelSystem = require(path.to.LevelSystem)

-- Connect to the signal
LevelSystem.OnLevelUp:Connect(function(newLevel)
    print("Congratulations! Reached level:", newLevel)
    SoundService.LevelUpSound:Play()
end)

-- In a Particle Effect Script
LevelSystem.OnLevelUp:Connect(function(newLevel)
    ParticleEmitter:Emit(50)
end)

See that? LevelSystem has no clue the UI or the Particles even exist. That is decoupling. That is clean.

With signals, systems communicate without holding hands. You can delete the UI script entirely, and your Level system won’t error—it just screams its signal into the void, and nobody answers. That makes debugging a breeze.


Wrapping this up

I hope this article helped you guys understand signals more. Lmk if you guys enjoy content like this and, if so, I’ll make more in the future! If there are any specific topics you’d like me to cover in the future, let me know as well.

Was this article of use to you?

  • Yes, it helped me understand why people use signals :heart_eyes:
  • No, I already knew everything discussed here :smiling_face_with_sunglasses:
  • No, I’m even more confused now :face_with_spiral_eyes:
0 voters
12 Likes

This only shows how to use someone elses signal module How do i actually construct my own signals? whats the code behind it?

I also dont understand when a signal would be useful. I prefer just calling a function in the other module. I would also never delete a module. there is no reason to do that.

2 Likes

In simple terms, Signal is just an entity that collects functions you give it to (called Connections) that fire whenever you “fire” the signal. In another way, it’s an organized way to fire functions when something related happens. Constructing your own signal is as simple as storing connections in a table, and then lookping through the table and call these connections. Though this isn’t practical as its not the most efficient nor the most ergonomic, so stick with a well known implementation./

for anyone new to signals without instances, just use signal+

Do you mean storing function references?
Either way, i still dont see how using signals is worth it when i can just call a function directly

1 Like

signals work across seperate scripts if you make a modulescript containing custom signals.
definitely call a function in one script if it’s only used there, but if an event happens and you want multiple scripts to react to it then signals are great for that

Yes, that’s essentially it. As for when a signal is worth to be used, just use it every time you want to use an event. It’s practically the same as Roblox events all developers use.

maybe i’m stupid but i would think this would go under Resources > Community Tutorials but idk i’m no topic expert

1 Like

You could do this, but this creates a scenario where your logic now is deeply embedded in the rest of your game’s code, making it more difficult to then reuse that code in any other games or systems you make.

Signals solve this by allowing your other systems to listen to events that happen from one system without actually having to special case logic inside that system specifically for it.

3 Likes

Another way to decouple is to use ecs queries, this way you dont even need to require the other module

Also, deleting a Signal object isnt very clean atleast imo, ive never seen a game destroy a Signal object unless it was very specific; youll still have memory leaks if you dont disconnect

Most script signals will contain a DisconnectAll method but again not very useful for non specific signals. Stay with the old method of tracking :V

How does this make sense? You know i can call the function from wherever right?

Yes, but in place of your system firing off a signal that this other system would listen to in order to execute that function you are now integrating special logic specifically for that game, and that system, to have its function called whenever that event occurs. This makes reuse of the system firing that function more difficult purely by the fact it now has hardcoded logic instead of more modular and flexible logic.

It’s better practice to make your systems as flexible and reusable as reasonably possible as this then can save you potentially hours, if not days of extra work when making other games or systems purely by the fact you may now have an easily reusable drop-in solution that requires minimal effort to integrate.

Both are middleware that slows performance down.
Use neither; optimize and be happy.
Glory to The New Luau Order.

Cut ze fluff, eat ze bug :raised_fist:

2 Likes

I can assure you that utilizing signals efficiently will not cause performance issues within your game. It’s mostly a design choice, and your message is a silly hyper-fixation on optimization which is negligible in this instance.

2 Likes

If he talks about the new luau order at least some part of the message is ragebait

but in seriousness it does depend on how its implemented if you do use it, for example if you use metatable OOP it will be slower than other implementations because of the metatable, but it wouldnt really be noticeable realistically

1 Like

The New Luau Order is secretly a campaign to promote code optimization.
You never ever should implement that; for example, when I need to yield script, I just do coroutine.yield() and insert this thread into a “stack” table that resumes it once the task is completed - simple, lightweight, no bloat.

2 Likes

code optimisation is (mostly) everything
but considering this is roblox, a lot of developers really wont care about optimisation, theyll only care once they start to notice lag from one single block of code.

all the little things add up though…

2 Likes

what is this, c++? lua is not meant to heavily utilize pass by reference that’s why all primitives are forcibly passed by copy

local bindable = instance.new(“bindableevent”)
task.spawn(function()
while true do
bindable:fire()
task.wait(5)
end
end)
return bindable
no “physical” folders, nothing polluting the datamodel. bonus points if you want whatever interfaces with your signal to not be able to fire it on its own or destroy it, just return bindable.event instead of bindable and now your function has total control over when the signal fires

unlike bindables your signals don’t work across different luau vms which means you aren’t utilizing parallel luau which means you aren’t actually as concerned with speed as you say you are

bindable:destroy

another thing you failed to mention is that luau signal implementations make your error messages incomprehensible. everybody who listened to you is now dumber as a result of doing so. never post ai slop again.

1 Like

That doesn’t change the fact that by using a custom signal implementation you can pass by reference.

I’ve added a statement about this to the original post to account for this use-case.

90% of use-cases won’t need to work across luau vms. that’s a completely separate article in itself.


image

This entirely depends on the signal implementation you’re using. With GoodSignal, the error is clearly defined, and only the lower half of the stack trace is mangled.

1 Like

Check this article out, it explains use-cases in a lot more depth than my post.