When exactly does PlayerAdded fire?

Lately I’ve been trying to better understand how ROBLOX’s task scheduler works, so I can make sure there are no racing conditions in my code without having to be overly cautious about it.
Suppose I have a Script in ServerScriptService with the following code and nothing else:

local players = game:GetService("Players")
players.PlayerAdded:Connect(function()
	print("Hello")
end)
for _,plr in pairs(players:GetPlayers()) do
	print("Hello")
end

I know that PlayerAdded fires when a player enters the game, and Lua is single-threaded, so the callback doesn’t run in parallel with the rest of the code. My question is: when is the callback’s thread resumed? Does it wait for the script’s main thread (or other scripts’ threads, for that matter) to finish/yield, or is there the possibility of it resuming before that? Do I need that “for” loop to check for any players that might have joined before the event was connected or no?

1 Like

Yes you need that loop, because from my experience I know that sometimes a player is added before the connection is created.

1 Like
game.Players.PlayerAdded:Connect(function(player) -- this will fire on join
	print(player.Name .. " has joined the game!")
	player.CharacterAdded:connect(function(character) -- this will fire on spawn, including after deaths
		print(player.Name .. " has spawned in!")
	end)
end)

The Character of the player often hasn’t loaded in when PlayerAdded fires.
The PlayerAdded event will run independently from all other code, so it will affect or be affected by other code in the same script.

1 Like

Also, as mentioned before, it fires only when a player has been added past that point, so if any players were already previously in the game it would not re-fire for them, because of course, the intended name and purpose of the event is when a Player is Added.

A simpler example is imagine changing a value and then hooking a .Changed event. It would not fire as you only changed the value before the event was listened to.

Typical convention is also to do this:

local onPlayerAdded = function(player) ........... end
Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in ipairs(Players:GetPlayers()) do
    coroutine.wrap(onPlayerAdded)(player)  --run it 'outside' of the main thread
    --wouldn't want to be yielding to try and load player data or until they spawn for example
end
1 Like

It’s actually not resumed at all! The reason you pass it a function is so that Roblox can create a new coroutine from that function, and run the coroutine.

Yes. Roblox’s task scheduler always has to wait for your code to yield before it can continue. All code in the game runs on one thread. In fact, wait() simply tells the scheduler to resume your code after some amount of time, and then calls coroutine.yield(), which yields your code and waits for the scheduler to resume it. If you resume the coroutine manually before then, Roblox’s task scheduler will actually try to resume it again, causing an error message! This is an incredibly insightful observation to make, as it really gives you a lot of information about how the scheduler actually works internally.

The thing with all Lua code is that a piece of code can only execute when every other piece of code is yielding or dead. Once your script finishes executing (after that for loop), its main thread is now dead. However, you told the Roblox scheduler to attach a function to an event, so once that event fires, a new thread is created from that function and then resumed in the scheduler as soon as possible.

It’s only slightly more complex than this explanation because disabling/deleting a script clears all of its connections and all threads and stuff, but that is mostly keeping track of which script created this stuff and what stuff a given script created. But hopefully my explanation gives you a lot of information about how scheduling works in Roblox. Ask me anything.

5 Likes

So, from what I understood, if the event is fired while a thread is running, the new thread created by the event cannot interrupt the one that is running, and if the event fires before being connected it won’t create a new thread, even if the connection is done later on the same frame. Am I correct?
Also, is GetPlayers()'s output changed (a new player added to the table it will return) immediately when the event is fired?

Also heads up, I’d run that second portion(GetPlayers() ) in its own thread for every iteration( using coroutine or spawn). Because if anything yields inside whatever you want to run for the player, the loop that iterates between the players that were in the server before PlayerAdded was connected will not run instantly, and that could cause issues for you.

So something like this:

local players = game:GetService("Players")
players.PlayerAdded:Connect(function()
	print("Hello")
end)

local function stuffYouWantToDoOnPlayerAdded(Player) 
--stuff
end
for _,plr in pairs(players:GetPlayers()) do
coroutine.resume(coroutine.create(stuffYouWantToDoOnPlayerAdded), plr)
--or
spawn( function()
stuffYouWantToDoOnPlayerAdded(plr)
end)
end

You don’t need to do this if you know your playerAdded stuff is instantaneous, but if it isn’t or wont be in the future, you might want to go ahead and toss the extra threads in for each player.

2 Likes

…Until it yields or dies. Then that new thread will be run as soon as possible. Yes.

Yes, when an event fires it instantly tells every callback function and queues them up to run.

The event is fired immediately after it changes. It’s like ChildAdded but only for Players. I believe that like ChildAdded, the child’s AncestryChanged event has even yet to fire when PlayerAdded runs.

1 Like

So does that mean that in the code I posted in the OP, if the player is added between the event connection and the loop, “Hello” will print twice for the same player?

Yes, if that somehow happens.

If you don’t yield between the connection and getting the list of players, though, no other code gets the chance to execute in the middle of yours so it’s impossible to miss anything.

1 Like

Thank you all for your help! :+1: :smile:
I’ll consider @Terrodactyl’s posts as the solution since they clarified my doubts, but that doesn’t mean other posts don’t contain useful information.

For those searching this up in the year 2025 and now with deferred events:
I was wondering this as I was trying to work out some race conditions. I did some testing with a bunch of callbacks bound to various runservice events, as well as playeredadded event in the micro profiler. The events of a frame are shown in this diagram

The backend core server script handles new player connections in the Replication Receive phase. I don’t know why the diagram depicts this event having no hooks, when it definitely should have hooks. This Replication Receive phase is also when the backend core scripts receive remote event firings. Any callbacks bound to Replication Receive are scheduled to run at the next resumption point (because of deferred events), and that resumption point is the Run Legacy Scripts point, i.e. if a script just happened to be created at an earlier resumption point in the current frame, it’s execution would coincide with callbacks bound to Replication Receive.

The Replication Receive phase comes after the PreRender phase (formerly called RunService.RenderStepped) and execution of any of it’s bound callbacks. After Replication Receive you have some other resumption points of the PreAnimation PreSimulation (physics) and PostSimulation events, which probably aren’t too useful but heres a more useful ordering:

  1. Functions bound to render step (using RunService:BindToRenderStep)

  2. Functions bound to PreRender (formerly .RenderStepped)

  3. Functions bound to remote events and PlayerAdded event, + new scripts that are running for the first time

  4. Animation, then physics steps

  5. Threads that used task.wait() or were inside of task.delay() and had the waiting timer run out in the current frame

  6. Threads bound to Heartbeat


MORE INFO about PlayerRemoving:
This below has nothing to do with the original post! This is just extra info for the curious!

This got me thinking about PlayRemoving. Strangely, I thought that PlayerRemoving would also fire at the same point as Player Added, but it doesn’t. In fact, it doesn’t occur at any point in the task scheduler cycle diagram at all!
From looking at the microprofiler, the PlayerRemoving event seems to be happening before anything bound using BindToRenderStep. (The server technically doesn’t render anything but this step still exists). It looks like:
-Player leaves on frame 6
–At very beginning of frame 7, before anything else runs, the backend core server script on the main thread (core server script is something we don’t have access to, it just exists when server is created), will see if any players had left on the previous frame. If a player has left, it does some cleaning up (I’m assuming player instance is removed from the Players service at this point too), then fires any callbacks bound to the PlayerRemoving event. At this point, from the perspective of the server, the frame hasn’t even begun yet!

1 Like