Assigning to Player.Character causes huge lag spikes (~500ms each frame)

Hello everybody! I’m working on some back-end systems for a game, and after creating a morphing (server) & animation (client) backend, I have 2 frames that take ~500ms each.

I have been staring at microprofiler dumps and doing research for awhile trying to figure out what the root cause of it is.

Both of the frames look like this (on different service workers between the frames)

Advanced Stuff

Both frames contain:

  • Thread (FG)
  • TS::Reschedule
  • Write Marshalled (NotOrderDependant; Game)
  • TS::JobStep
  • Write Marshalled

The first frame used Service Worker D and the second frame used Service Worker A


Here is a list of every class inside the character:

{
["Animator"] = 1,
["Bone"] = 57,
["Folder"] = 1,
["Humanoid"] = 1,
["MeshPart"] = 4,
["Motor6D"] = 5,
["Part"] = 2,
["SurfaceAppearance"] = 3
} 
Old Hypothesis (DISPROVEN)

After doing some research, it seems part of the issue is the network serialization (write marshaling), but nothing looks too out of the ordinary as I’m not sending large data models over the network (the base roblox replication is the biggest thing to worry about)

After doing some research, the engine call to Player.Character changing is causing the issue. Root cause is still unknown.

I am using the Packet Networking Library for my client-server communication.


Due to the unreleased nature of the project, I cannot share full codebases or full models, but I can share snippets and information about them.

Assume that any and all code is properly referenced and working.

MorphController:DoMorph()

function MorphController:DoMorph(playerToMorph:Player, newChr:string)
	print(`[MorphController] Attempting to morph {playerToMorph.Name} to {newChr}`)
	local data = self.Util:GetCharacterData(newChr).RigInfo
	if data == nil then warn('data nil') return end
	
	local oldCharacter = playerToMorph.Character
	if not oldCharacter then return end
	
	local oldRoot = oldCharacter:FindFirstChild("HumanoidRootPart") or oldCharacter:FindFirstChild("RootPart")
	debug.profilebegin("Clone Morph")
	local morph = self.Util:FindFirstDescendant(game:GetService('ServerStorage').Characters, newChr):FindFirstChild("Morph"):Clone()
	debug.profileend()
	if morph == nil or oldRoot == nil then warn('cant morph, something is nil (oldroot, morph, chrname)', oldRoot, morph, newChr) return end
	
	local newHumanoid = morph:FindFirstChildOfClass("Humanoid")
	if newHumanoid == nil then warn('new hum not found') return end
	
	local newRoot = morph:FindFirstChild("HumanoidRootPart") or morph:FindFirstChild("RootPart")
	if newRoot == nil then warn('new root not found') return end
	
	playerToMorph:SetAttribute("CurrentCharacter", newChr) -- for animations
	
	morph.Name = playerToMorph.Name
	newHumanoid.HipHeight = data.HipHeight
	newRoot.Anchored = false
	
	morph:PivotTo(oldRoot.CFrame + (data.SpawnOffset or Vector3.zero))
	
	debug.profilebegin("Set Char")
	playerToMorph.Character = morph
	debug.profileend()
	
	debug.profilebegin("Parent Morph")
	morph.Parent = workspace
	debug.profileend()

      -- this is all set up as documented in the Packet docs
	self.UpdatedCharacter:FireClient(playerToMorph)
	self.FixCameraPacket:FireClient(playerToMorph, data.CameraOffset)
	self.ChangeAnimData:FireClient(playerToMorph)
end

AnimationController:UpdateAnimations()

function AnimationController:UpdateAnimationData()
	local currentCharacter = self.Player:GetAttribute("CurrentCharacter")
	if currentCharacter == nil then return end

	self.CharacterData = self.Util:GetCharacterData(currentCharacter)
	if self.CharacterData == nil then return end

	
	print("[AnimationController] Updating Animation Data")
	
	for name, id in pairs(self.CharacterData.Animations) do
		if id == nil or id == "" then continue end
		local list = typeof(id) == 'table' and id or {id}
		for i, id in ipairs(list) do
			local track = Instance.new("Animation")
			track.AnimationId = string.match(id, 'rbxassetid://') and id or "rbxassetid://"..id
			self.AnimationTracks[typeof(id) == 'table' and name..i or name] = track
		end
	end
	
	self:ChangeState(self.State) -- redo animations after morph
end

IMPORTANT NOTES:

  • My debug.profilebegin() calls are NOT lining up with any of the long frames.
  • self.Util:FindFirstDescendant() is NOT causing long frame times (finishing in ~200μ)
  • The model being used for this only has 323 total descendants and has a mid-poly tri-count.
  • Remember, huge data models are NOT being sent over the network.

If you need any more information that might help, please don’t be afraid to ask!

3 Likes

what is happening to the old character? I think you’ve identified it likely being a built in replication issue correctly. There is a somewhat known issue where write marshal will hang on the first destruction or creation of any script type. I have personally ran into this and it seems to be the most common issue but there is quite a bit of potential causes.

There are reports of it happening when reparenting an object containing a script, there is also reports of it relating to tools (first creation/destruction similar to scripts, also sometimes on first tool equip but if I had to guess the core issue would be reparenting the tool similar to scripts).

Unfortunately it seems to be really arbitrary what causes it and often the same setup won’t even do it on different devices/roblox places. My first thought is that somewhere you are clearing references to the old character which contains scripts and when garbage collection clears it you are seeing the spikes. If this is the case it is pretty certainly the known replication bug which unfortunately you just have to pray roblox fixes or come up with a way to not destroy the scripts.

1 Like

Hello! Thank you for responding.

I was literally about to hit send on a longer, detailed reply before I had the idea of testing something that I’ll explain later, and that seems to fix it.

It seems as your idea of it being something with the garbage collector relating to scripts is definitely the root cause.

I ended up using this code that more properly worked around the issue (look at my old message to see my OG fix), now there is no forseeable lag other than ping & replication times.

for _, v in pairs(oldCharacter:GetDescendants()) do
	if v:IsA("Script") or v:IsA("LocalScript") then
		v.Enabled = false
		v.Parent = workspace
	end
end

Don’t worry! Throwing it into the workspace isn’t going to be the final solution, I’ll end up manually cleaning it later, this was just for debugging purposes.


I still don’t think that this fixes the underlying issue at hand, and I’ll create another bug report about it with alot of detail since it’s pretty much out of my control unless I go work at Roblox (/j)

I’ll also drop the microprofiler dump here:
microprofile-20260713-065903.zip (2.8 MB)

File Safety Information

I know downloading a zip file off the internet is sketchy! Here’s some stuff to prove this file is safe:


VirusTotal link


Old Message w/ Incorrect Information (archival purposes)

Currently, nothing was happening to the character, just letting roblox GC do it’s job.

I ended up hacking together a small fix relating to this bug happening only on the first character re-parent.

game:GetService("Players").PlayerAdded:Connect(function(plr)
	plr.CharacterAppearanceLoaded:Once(function(chr)
		chr.Archivable = true
		local c = chr:Clone()
		c.Parent = workspace
		plr.Character = c
	end)
end)

But the thing that concerns me is that this clones the exact character, including scripts, so that means your idea of

“There are reports of it happening when re-parenting an object containing a script”

could be wrong (I still agree with you though).

Also, I tried to remove all the scripts


If there is genuinely nothing else to do other than using the hacky code, I’ll end up sending another bug report to Roblox about it with a more detailed explanation.

1 Like

Just a final update:

This issue has been documented pretty heavily in this topic.

This is completely a Roblox issue with it’s handling of garbage collection and it’s scripts.

Good news though! It seems to only be a major issue on Studio play-tests (NOT team tests) since your PC creates the server locally. On a live server, this doesn’t exist, at least from a client perspective.

A workaround has been documented in the same topic:


I have re-stated this in a reply on the linked topic, and will move any and all communication regarding this bug to that topic:

2 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.