Things to look out for when using Network Events (RenderStepped, Stepped, Heartbeat)

The names RenderStepped, Stepped, and Heartbeat are technically deprecated because they should have a replacement soon. For now, I’ll keep them there as they’re the only options and there’s no actual documentation for the new ones. They should however just be one new event and the other ones being renamed.

If you find any information here that you believe is wrong, PLEASE send me a DM asking me to change it instead of replying here. I will hide that portion of the post until I can prove the information true of false. I doubt there will be anything wrong here at time of posting, as I’m only mentioning things I can confirm.

Hi!
This topic will show off things to be aware for when using RunService events, or anything that derives from them.

That means we’ll also talk about

  • Wait functions, yes, task.wait still counts here.
  • A bit about tweening, I guess.
  • Maybe other stuff I can’t think of right now :eyes:

Using wait functions

Wait functions derive from RunService events, usually Heartbeat, in some way or another.

And remind yourself, it’s not just wait, but also task.wait. I’m talking about bad practices, and while wait is worse, using task.wait won’t fix those issues.

The problem with using wait functions is not accuracy, unlike what most people think.
Wait functions aren’t the worse thing ever, however how you use them can be.


Do I need to use a wait function?

Probably not.
I’m going to show you some examples.

"My code only works if I put a wait in there!"

This actually seems to be pretty common, it isn’t something I experienced too much or if at all, however I see a lot of people “fixing” their code using this.

As far as I know, this usually seems to be an issue with the order that events are fired, on which many people do not understand.
Events are fired from the last connected thing, to the first connected one.

For instance,

Event:Connect(function()
    print("This fires last!")
end)

Event:Connect(function()
    print("This fires first!")
end)

What this can cause, is that you would expect something to have already been processed once the code you asked for runs, but it didn’t, now your code doesn’t work and for some reason you learned using wait() can help something work, and sure enough it did!

In the past, yeah sure, waiting a frame or so would be the only way to fix this, however now with the task library, this isn’t hard to do at all!

The task library introduces a method, defer, defer schedules a thread to be ran later, still usually in the same frame, but later.
You can learn what this later means here, however I believe you shouldn’t write code to be specific to behaviour like this.

Anyhow, delaying a function without using wait is simple as this!

task.defer(coroutine.running())
coroutine.yield()

This will schedule the current code to be ran later, usually right after all the connections were fired.

If you have some code that used wait for this, you should try out replacing it with this instead, and seeing if it fixes the issue for you!


"I have a timer which uses wait!" or "I have a loop using wait!"

If you’re using wait for doing timers, something like this

local timePassed = 0
while true do
    wait(1)
    timePassed += 1
end

Then you should know that you’re doing a LOT of things wrong.
First of all, the time that wait actually takes, is not being calculated. So it will always be off. In this case, it would be a case of just += wait(1) instead, however, this still isn’t good.

If you have a loop that never ends in your code, that’s a horrible thing, doesn’t matter if it’s in a coroutine or not, you shouldn’t have one. It messes with garbage collection and even in a routine, it can still be incredibly problematic.

The best way to do this, is by handling it using RunService events themselves, which isn’t hard to do at all!

local TimePassed = 0
RunService.Heartbeat:Connect(function(deltaTime) --> delta time is the time passed since the heartbeat call!
    TimePassed += deltaTime
    if TimePassed < 5 then
        return
    end

    TimePassed = 0

    -- Code that will run every 5 seconds
end)

The another good thing about this is that you can :Disconnect the connection easily, stopping the loop with no issue.

I don’t think I need to explain how to handle things like calculating timers with the information I just passed you, basically same thing.


"I just use wait for something that takes the same time!"

For tweening for instance, you don’t need to use another wait to wait for the same amount of time.
You can use events. Wait’s biggest fear.
In the case of tweens, probably where people use wait wrongly the most, you use the Tween.Completed event.

There are multiple places where the same applies, for example, Sound objects have .Ended.
You should ALWAYS look to see if there’s anything like that for the thing you’re waiting for.

RBXScriptSignals (Events) have more than just :Connect, they also have :Wait.

:Wait works by yielding the code until that event is fired. Note that it also returns the arguments passed through when that event was fired.

local Tween = TweenService:Create(Instance, TweenInfo, {
    Position = UDim2.fromScale(0.5, 0.5)
})

Tween:Play()

Tween.Completed:Wait()

Not only is using events faster to react to, meaning it will happen pretty much the instant that actually ends, it also is better for performance, as there’s no polling going on. We’ll talk about what polling is later.

In the case that what you’re waiting for is something that you made, like custom tweening or something, then you should look into creating your own custom events. You can do that by using a Signal library. You should look into mine. :wink:

They’re easy to use, and usually really fast too, I highly recommend looking into them.

If it’s just one thread waiting around, then you can code the yielding and everything yourself, using coroutine.yield and some others.

local thread = coroutine.running()

--\\ Later on:

coroutine.yield() --\\ Stops the current thread
print("Resumed!")

--\\ On some other part of the code, let's say it's custom tweening, i don't know.

-- ...
task.spawn(thread) --\\ now the code runs again!

--\\ "Resumed" is printed!

Using these is better if you’re messing with only ever one thread that you need to resume, otherwise, just use a signal and you’re good to go.


"I don't fit into any of these... Am I fine using wait?"

Probably. Like I said, a wait function isn’t a monster, as long as you’re using it properly, you’re fine.
The thing is that most of the time you don’t need to use it.

Anyhow, wait has some inconsistent behaviour, use task.wait instead.

If you’re unsure, hit me up with a DM, I will happily answer you. Your code can turn into an extra part of this post, if it’s a good example.



Why is wait bad?

Well, because first of all, usually when you’re using wait, you’re usually already doing something wrong.
That’s basically it.

When you need to use a wait function, it usually means you could do better.

Usually one wait call for a use is enough, but if you’re waiting for the same thing in other places in your script, then I highly suggest you to look into ScriptSignals, yet again, and fire one when that wait is done, and then listening to it and using :Wait on other parts of your code.


Using RunService events

We are out of wait town, going into non-yielding town!
This is more so talking about things you can do wrong while using RunService.


Polling

Polling is the computer-equivalent of asking “Are we there yet?” every 2 seconds. In this case, it’s way more frequent than 2 seconds.
Polling in excess can cause your game to slow down. So stay away from it if possible.

Most polling looks something like this:

while not (condition) do
    RunService.Heartbeat:Wait()
end

This is horrible! Not only you’re running a thread multiple times a second for no reason, just like what I talked about before, usually you’re doing something like indexing a table, checking if there’s something inside one, or literally anything that demands some searching, etc.

Like I said before, use custom events for these things! Use a Signal class, you’re already doing it right! Your issue is resolved.

Custom events are extremely useful for things like :WaitFor functions in other libraries.


Memory Allocation every frame

This is a big rule in game development. At least AFAIK.

You should not allocate memory with anything every frame.
The thing here is if you’re allocating every frame, or frequently in that basis at all.
If you’re allocating here and there inside a handler, that’s probably not an issue.
But if you’re doing it frequently at all, then yes, it is an issue.

Setting anything to a local doesn’t count as memory allocation. Locals allocate memory for themselves automatically, that doesn’t count as memory allocation.

Things that would count as memory allocation are things like, creating a new table, setting things to a table, creating a new object, instance, things like those.

A big causer that people seem to not notice is using :GetPlayers, :GetChildren, or similar functions to that, in such applications.

:GetChildren for example like it’s many counterparts, can allocate memory when called, as they are creating a new table each time. Doing that each frame is horrible.

For these things, as long as you’re not mutating it, you can cache these values, add some .ChildAdded / .ChildRemoved listeners, and you have just one table for those things that does not contribute to this bad practice.

local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local PlayersList = Players:GetPlayers()

Players.PlayerAdded:Connect(function(player)
    table.insert(PlayerList, player)
end)

Players.PlayerRemoving:Connect(function(player)
    local index = table.find(PlayerList, player)
    if index then
        local tableLength = #PlayerList
        PlayerList[index] = PlayerList[tableLength]
        PlayerList[tableLength] = nil
        --\\ Quick remove;
    end
end)

RunService.Heartbeat:Connect(function(deltaTime)
    for _, player in ipairs(PlayerList) do
         --\\ Do something with every player! :) No extra memory allocation here!
    end
end)

If you have some code lying around using something like this, you should deeply consider adding this solution to it!


Always running things every frame

This isn’t per say a bad thing, it only is if you don’t need to.
You can bundle updates to certain things every 30 frames, or 30 seconds, or 5 seconds, 0.5 seconds, etc.

It isn’t hard to do, and I highly recommend you do this for things you don’t need to do every frame.
If it doesn’t break from bundling / if it doesn’t depend on updating every single frame, then you should look into bundling that update.

You can just do what I talked about above on the “I have an loop using wait!” section.

Something like this should work for you.

local RunService = game:GetService("RunService")

local InvertalTime = 30
local TimePassedSinceLastUpdate = 0

RunService.Heartbeat:Connect(function(frameDeltaTime)
	TimePassedSinceLastUpdate += frameDeltaTime

	if TimePassedSinceLastUpdate >= InvertalTime then
		return
	end

	local deltaTime = TimePassedSinceLastUpdate
        TimePassedSinceLastUpdate = 0

	-- Do something with the DeltaTime.
end)

If you need to, for something like cameras, yes, just update it right away, but for things like these, you’re losing a good chunk of performance by not doing so.



With all that said, I’m tired, I’ve been writing for 4 hours straight, writing code in a normal page, keeping track of 4 spaces, thinking of new things to discuss about, tomorrow I’ll be adding anything I remember, right now I’m exausted. Thanks for the read.

Any questions reply to me here and I’ll be happy to answer you. Just remind yourself for any discussion about information you believe should be changed, DM me instead, this includes correcting typos as well.

47 Likes

I really like your tutorial please make more like this one! :smiley:

1 Like

First of all, nice post! I myself didn’t know Signals existed and definitely looking onto them so I can add better functionality to my current project’s classes.

Although it’s not really recommended to use task.wait for things that can be replaced with events and such,task.wait could probably be considered safe in that way. The task library post also says it:

task.wait is an improved version of wait which schedules the current thread to be resumed after some time has elapsed without throttling.

As many asked and stated already, task.wait depends on RunService.Heartbeat so using it is equivalent to using Heartbeat:Wait() (which if not wrong, is the main method fastWaits use to achieve the same effect as wait without throttling) but with the extra of being able to add yielding time.


As a final mark, maybe talking a little bit about metatables in the post would be cool? They have some not-really-popular metamethods that allow for table optimization such as __newindex which will fire each time a new index is added, adding a way to check if a table has a specific value without having to poll a code to check it every x seconds.

2 Likes

Yes. Using task.wait is fine for the most part.

It only actually becomes a problem when you have A LOT of waits running at once.

As to task.wait() being the same as Heartbeat:Wait(), it should be behaviour wise, however the DeltaTime it returns won’t.

Turns out task.wait calculates deltaTime since it was called, not since the last frame, in my opnion, at least for my use cases, that’s not the behaviour I would expect or want. task.wait should be good for waiting for a rate limit for instance, since those would be checked os.clock and using a wait that uses deltaTime since the last frame could result it in resuming slightly before the rate limit is considered opened.

This post isn’t about table optimization or optimization for general use, I guess you mean using __newindex for detecting new objects?

Hm… I guess that’s fine, if you wanna use it to connect events to it, that sounds interesting. I didn’t ever think of that. Could add it in the future.

I guess for most usage it’s easier to just fire the event when you set something, it’s faster too.

I would expect in Luau for using __newindex making normal setting slower / deoptimized. Luau has a lot of weird tricks tbh which no one really knows about.

With that said, if that’s something that the other people reading this are interested in, sure, you can use it.

1 Like

Well, yes it actually affects performance just as __index does but by table optimization I meant easier and faster ways to retrieve info from them rather than them actually being faster or smaller.

1 Like

Update

  • Made sections into expandable parts, making it easier to read.
  • Added a section talking about using Stepped. It is still in construction.
1 Like

Beautiful thread, also I was wondering about task.wait() being equivalent to heartbeat so I tested it… in the command line… and uh, this is weird.

image
seems like it’s consistently faster… I’m so unbelievably confused right now.
image
This may just be a quirk of the command bar and task.wait()/Heartbeat being managed a bit differently in the Edit mode (not play solo/etc) by the task scheduler. Either way awesome thread, useful.

I won’t post any further since this has a solution now, but I am happy to edit this to reply.

Keep it up :+1:

3 Likes

A lot of people don’t know this, but

This is because task.wait counts DeltaTime since it was called, not since the last frame, which RunService doesn’t have this issue with.
I don’t recommend using it’s DeltaTime because of that, as I don’t think it would be useful for anything anyhow.

The time it should take is the same.

If you notice, doing Heartbeat:Wait() first and then task.wait() (unlike what you did here), you will notice that the DeltaTime isn’t actually that different.

Doing it just randomly:

image

Heartbeat returns 1 second because I did this right after on the instance launching up, Heartbeat returns the time since the last frame, there was no last frame, so it’s since the game launched, this is normal for this case. This isn’t an issue with normal use on games.

Doing Heartbeat:Wait() right before running task.wait

image

This behaviour of counting DeltaTime since it’s called does also mean that a task.wait (with an actual number) can take some frames more than you should think, compared to a custom wait function.

This rule, (I believe it’s a rule?) only counts for help posts. Don’t worry, resource posts are supposed to have questions on them. I only set replies as solution as a way to pin them.

1 Like

Sorry to get back late, but this is quite interesting here.
I believe the deltatime on task.wait() would actually be more useful as it signifies the time it took for the yield to finalize (start to finish,) but I may be overthinking/underthinking this whole ordeal.

Either way, I believe task.wait() is probably going to be more performant due to the simple fact that it doesn’t rely on namecall-style instance methods, which have since been reworked for the luau vm and are significantly faster, but they’re still liable for defining your own reference

(P.S: Please do not use expanded or “absolute” paths such as for example game:GetService("RunService").Heartbeat:Wait(). If you’re learning lua or trying to improve, variable references are an extremely simple subject to learn and implement, and they are much better. Both for readability and for performance in heavy-use applications.)

Aside from that, I’d actually need to write up some benchmarks to test this.
Also, I mean, you could indeed add Heartbeat:Wait() before task.wait() but that sort of defeats the purpose of its existence, does it not?
Not trying to be rude, but feel free to clarify it for me.

2 Likes

wait hasnt hurt anyone but people who think wait is bad

3 Likes

It’s hurt me several times before, I’ve had a system where I used wait to do something, bit the wait was offset because that’s just how wait works, and that resulted in timings being off, etc.

0.003 ms offset hasnt hurt anyone

2 Likes

0.003? No, it was nearly 0.1s off.

still doesn’t hurt anyone
at least name 1 case where it was a game-breaking issue

1 Like

I never said gamebreaking in any way possible? I just said it hurt me while doing something that time. just taks.wait() is better in general, but wait() isn’t game breaking.

Thank you so much for this, I’ve always had a vague idea that I’m an idiot whenever I work with run service or coroutines but seeing this article and looking up all the words I didn’t know helped me a bit I think, I’m still confused on a majority of it but I can just keep re-reading till I get it.

It depends. I think task.waits behaviour is good for certain use cases like for example waiting for a rate limit for some service ends for example, but in that case the deltatime that it returns isn’t useful for much, because what would you do with that deltatime? You’re waiting for a rate limit, so I don’t know why you would want to use DeltaTime in that case, especially since for rate limits you would be using os.clock.

It really does depend on your usage, what you’re using wait for.

And to be honest, I haven’t even used wait for anything recently, for instance, for this entire Fusion example, with quite a few scripts and still didn’t need it, a lot was event based.

I’m here! :flushed: Had some comments and questions!


Wrt your TimePassed example, I think there would be a lot of preference for just updating a variable with the last run time and comparing if the current time is greater than the time you want to wait for rather than incrementing a variable. One comparison, set only if the time has elapsed.

local TIME_BETWEEN_RUNS = 5
local lastRunTime = 0

RunService.Heartbeat:Connect(function ()
	local timeNow = os.clock()
	if (timeNow - lastRunTime) < TIME_BETWEEN_RUNS then return end

	lastRunTime = timeNow

	-- Code that should run after 5 seconds have passed, repeat continually
end)

For the third point under “Do I need to use a wait function?”, I see that you chose to spawn a yielded thread instead of using coroutine.resume. Since I’m not all too familiar with task and coroutines right now but am slowly picking things up, any reason why you specifically chose to task.spawn it? What would differ if you used coroutine.resume instead to pick the thread back up?


If this is talking about the now-deprecated wait, there’s a few problems with it and calculating numbers aren’t the reason why it’s problematic. With paraphrasing, if I recall correctly, three off the top of my head: wait runs on a legacy 30hz pipeline, wait spends additional time waiting for an open slot in the task scheduler to resume the thread and there’s a certain resume budget (this may apply to spawn and not wait; apologies if I have this wrong!).

Thought it might be good to explain the specifics on why wait is bad and what causes the “lag” to occur because otherwise it’s a little confusing trying to grasp why things happen the way they do. Wait isn’t really calculating any numbers and even then calculations aren’t too computationally heavy.


Disagreed as per the above sample I provided which compares calling times instead of incrementing a variable every frame. In practice the code you initially proposed probably has no weight at all because it’s just a straight calculation on a piece of data on the stack versus calling a function. I dunno, os.clock seems more convenient here. Any difference you notice between use of os.clock and deltaTime?


I smell a fallacy in here. “It doesn’t hurt anyone” is a poor take on what the thread is trying to teach and it’s in general a non-talking point. Please come up with something substantial when replying.

The answer to using wait in your experiences is that you should generally never have to unless your specific case calls for it. Don’t set yourself up for failure. Create event-driven systems instead so you can predictably know when to handle experience events as they are fired.

The problem isn’t necessarily wait itself (unless it’s legacy wait which then that is the problem) but rather the practice implications that arise out of its use and how developers are deploying it in their experiences, a lot of such points which are covered in the OP. As another example, a particularly egregious use of wait is as the conditional of a while loop.

5 Likes

The following conversation is hidden as it’s a hard thing to explain, and has since been removed from the topic, and doesn’t provide much value, or fix many issues.

I took for some reason too long to get what was going on here, anyway this works, but I just don’t like os.clock because it’s getting the time from the time you called it, which can differ everytime, I prefer using DeltaTime because it shouldn’t have that issue, it’s just the time since the last frame.

Also, os.clock is just a little bit slower to call, so I personally I’m not a big fan given there’s already a value I can use that RunService gives me.

Yeah actually,
so…
coroutine.resume, is racist doesn’t propagate the error to the console, so it’s kind of a pain to debug code when using it, I had a bunch of errors and I wasn’t getting them for some library I was making because I was using coroutine.resume, it was a pain to figure out what was going on.

Also coroutine.resume does seem to have some problems apparently with resuming user-created threads (or roblox created threads?) on which you had to yield the code with Heartbeat:Wait on the past so that it would give you the parameters.
I’m not entirely sure about how that worked but I know there were some issues with it.

task.spawn/defer do have their own fair share of issues, however they seem to be things that only annoy people who have code with weird behaviour.

For instance, it seems as if task.spawn/defer make it so that any thread resumed (or created) by them will not stop if the script is destroyed or it’s .Disabled becomes true.
But I would expect people to be disabling their scripts in runtime to be doing some weird stuff.

Well yeah, I was talking about wait functions in general,
for instance if you’re using something like this.

local function Wait(n)
    local spent = 0
    repeat
        spent += Heartbeat:Wait()
    until spent >= n
    return spent
end

I’m not actually talking about you know, just calculating that number, but instead the fact that using Heartbeat:Wait() would be yielding and resuming once Heartbeat fires, and when it does, it resumes that thread.

Which when you have a bunch of waits running at once would mean there would be a bunch of threads being resumed and yielded back again just to calculate one number for each wait, which could potentially have a performance penalty.

I can’t confirm how bad this is, but well, I guess if you’re using something like BetterWait on which there is only ever one thread seeing these, I would expect that to be at least better.

But yes, everything you talked about above does apply to the global wait function, so thanks for the extra info added to the post.

Spawn does run one wait() before running what you asked it to, so it would apply I guess.

Well, my biggest worry like I said, is that it would be because there could be a slight offset from the time that it actually is taking, I can’t confirm how bad this could be, but I still believe that you should just use DeltaTime to calculate these things.

Calling os.clock takes longer than just using the DeltaTime, that RunService gives you anyway, so I don’t see why not.

Let me show you an example on which I don’t like how using os.clock changes results.

local function Wait(n)
    --\\ Wait function that uses os.clock instead of deltatime to calculate time spent

    local timeNeededToReach = os.clock() + n
    repeat
        Heartbeat:Wait()
    until timeNeededToReach >= os.clock()
end

for _ = 1, 2 do
    task.spawn(function()
        Wait(2)
        print("Wait finished!")
    end
end

In this example, in certain situations, these two wait calls can be resumed at completely different frames.
And that’s because os.clock changes in these two instances, therefore the time that each of them agree to be resumed can differ.

Sure, most of the time this wouldn’t be that big of a difference, but, with time these could become unsynced.

This isn’t an issue if you’re using DeltaTime, so if you want consistency with when different things are resumed, then just go with DeltaTime, if not I guess you can use os.clock, it might be just little bit more expensive to run each frame but that’s fine.
It’s mostly personal preference, but I believe most people should just go with using DeltaTime anyhow.

The only I guess good thing with os.clock is that it has some extra decimals, but I don’t think those are important for most use cases.

I personally prefer having the behaviour of using DeltaTime than os.clock because of those reasons.

@colbert2677

After testing out, this module, using os.clock and using DeltaTime, I can confirm that using os.clock, gives you pretty unstable timing, or at least it’s not like you get can pretty stable timing with it.

image
image

As you can see, it starts to get pretty unaccurate, pretty fast.
After some time, it was already offset by 0.2 seconds.
In my case, it seemed to offset by about 0.1 seconds every minute.

Now with DeltaTime, I’m not properly handling “excess” delta time in this case.

image
image

In this case, close to os.clock but it feels like it doesn’t get as bad as quickly.

Now with DeltaTime, but properly handling “excess” delta time.

image
image

It could run for 10 minutes, and it would still be consistent, printing always at those margins, and not getting offset by any count.

Of course, it’s not like I’m handling excess time from os.clock in this example, I don’t think you can do that anyways, so :woman_shrugging:
Even if there was a better way to handle it using os.clock, there is still no reason why I would think using it instead of using the DeltaTime that RunService gives you would be a good idea.

The only good thing is that it might be more understandable to read if someone doesn’t understand how DeltaTime works, but that’s pretty much it, and I don’t think that’s a good reason, especially when it can get expensive with multiple calls.