Blog/Roblox/🧠Advanced Strategy

Roblox Heartbeat vs RenderStepped vs Stepped: Which Loop Should Actually Run Your Code

Every Roblox tutorial teaches the same loop — while true do wait() end — and every one of them is teaching you a bug. Roblox gives you nine documented points per frame where a script can wake up, and picking the wrong one is why your camera jitters, your physics reads a frame late, and your Motor6D edits get stomped by the Animator. Here is what each RunService event actually does, why the old names still work despite a 2021 deprecation scare, and the 29-millisecond tax the legacy wait() charges you.

Published August 1, 2026·12 min read·By Mythras
The Roblox MicroProfiler in detailed mode showing a 152.040 millisecond frame, where the Sched, Worker::runJob, Heartbeat, RunService.Heartbeat and Script_ShadyRays bars all stretch across the full width of the timeline.

Open any Roblox scripting tutorial from the last decade and you will find this:

while true do
	wait()
	-- do the thing
end

It runs. It also charges you a 29-millisecond floor per iteration, fires at a point in the frame nobody chose deliberately, and gives you no delta time to scale movement with. There are nine documented moments per frame where a Roblox script can be resumed, and "whenever wait() feels like it" is not one of them.

Picking the right one is not pedantry. It is the difference between a camera that tracks smoothly and one that shimmers, between physics you read on the correct frame and physics you read one frame stale, and between Motor6D edits that stick and Motor6D edits the Animator silently overwrites.

Why while true do wait() end is not a game loop

The MicroProfiler capture at the top of this post comes from Roblox's own performance documentation, and it is worth reading carefully. The frame took 152.040 ms — call it six and a half frames per second. The stack tells you exactly who did it: HeartbeatRunService.HeartbeatScript_ShadyRays, one script bar running the entire width of the timeline. Every neighbouring frame in the strip above it is a normal orange sliver. One connection did that.

The lesson is not "Heartbeat is slow." Heartbeat is fine. The lesson is that whatever you connect to a per-frame signal becomes part of the frame budget, and the engine will happily wait for you. A game loop is not a place to dump work; it is a scheduling decision.

So make the decision on purpose.

The nine places your code can wake up

Roblox's deferred engine events documentation publishes the exact list of resumption points — the ordered moments within a frame where a suspended script can be resumed. In order:

  1. Input processing (once per input to be processed)
  2. RunService.PreRender
  3. Legacy waiting script resumption such as wait(), spawn(), and delay()
  4. RunService.PreAnimation
  5. RunService.PreSimulation
  6. RunService.PostSimulation
  7. Waiting script resumption such as task.wait(), task.spawn(), and task.delay()
  8. RunService.Heartbeat
  9. DataModel.BindToClose

Two things jump out of that list immediately.

Legacy wait() resumes at position 3 and task.wait() resumes at position 7. They are not the same scheduling slot. If you have a mixed codebase, some of your loops are running before physics and some after, purely because of which wait function the author typed.

BindToRenderStep is not on the list at all, because it is not a resumption point for a yielded thread — it is a bound callback that the render step invokes directly, with an explicit priority number. More on that below.

The new names and why the old ones still work

Roblox renamed these events, and the internet has been confused about it since 2021. Here is the mapping, straight from the engine reference:

Legacy eventModern eventDocumented timing
RenderSteppedPreRender"Fires every frame, prior to the frame being rendered."
PreAnimation"Fires every frame, prior to the physics simulation but after rendering."
SteppedPreSimulation"Fires every frame, prior to the physics simulation."
HeartbeatPostSimulation"Fires every frame, after the physics simulation has completed."

Now the part everyone gets wrong. In early 2021 the API dump marked Heartbeat, Stepped and RenderStepped as deprecated, and a wave of DevForum threads and YouTube videos told everyone to migrate immediately. Then the new events had bugs, the old names stayed in millions of scripts, and the deprecation quietly came off.

Check it yourself: as of this writing, the Deprecated section of the RunService reference page lists exactly two members — Reset() and Run(). Not one event. Heartbeat, Stepped and RenderStepped are all documented as live, supported events alongside their modern counterparts.

So: use the new names in new code because they say what they do, but you do not have a migration emergency, and a codebase full of Heartbeat is not technical debt. What is worth fixing is code that picked the wrong phase.

One genuine difference to know. Stepped is the odd one out on arguments — it passes two, (time, deltaTime), where every other event passes delta time alone. PreSimulation passes a single deltaTimeSim. This is the source of a classic bug:

-- WRONG: dt here is the total run time, not the frame delta
RunService.Stepped:Connect(function(dt)
	part.CFrame = part.CFrame * CFrame.new(0, 0, -10 * dt)
end)

-- Right
RunService.Stepped:Connect(function(runTime, dt)
	part.CFrame = part.CFrame * CFrame.new(0, 0, -10 * dt)
end)

-- Better: one argument, no ambiguity
RunService.PreSimulation:Connect(function(dt)
	part.CFrame = part.CFrame * CFrame.new(0, 0, -10 * dt)
end)

If you have ever seen a part teleport across the map on the first frame, this is usually why: Stepped's first argument is elapsed run time, which after ten minutes is 600, and 600 times your speed is a very long way.

deltaTime is the argument everyone throws away

Every one of these events hands you the seconds that elapsed since the last frame. PreRender gives you deltaTimeRender; PreAnimation, PreSimulation and PostSimulation give you deltaTimeSim; Heartbeat gives you deltaTime. Use it.

The arithmetic is not complicated. A client running at 60 FPS has roughly 16.7 ms frames. A client running at 30 FPS has roughly 33.3 ms frames. If you write this:

RunService.Heartbeat:Connect(function()
	part.Position += Vector3.new(0, 0.1, 0)
end)

…your part moves twice as fast on the good machine. That is not a stylistic problem, it is a gameplay problem — projectiles, dashes, and cooldown bars all desync by hardware. Multiply by delta and the units become "per second," which is what you actually meant:

local STUDS_PER_SECOND = 6

RunService.Heartbeat:Connect(function(dt)
	part.Position += Vector3.new(0, STUDS_PER_SECOND * dt, 0)
end)

Roblox does not lock every client to one frame rate, so treat frame rate as a variable you never control. If a value should be constant across machines, express it per second and scale it. For anything that is really an interpolation over a fixed duration rather than a per-frame nudge, stop hand-rolling it and use TweenService — the engine does the delta math and the easing for you.

A Roblox MicroProfiler timeline in detailed mode showing stacked per-frame job bars including runJob at 15.250ms, Perform at 14.982ms, Scene at 13.955ms and RenderView at 10.784ms, with a column of abbreviated thread labels down the left.

PreRender and BindToRenderStep are client only

This is the one that produces the most "why doesn't my code run" posts. PreRender and RenderStepped are tied to rendering, and servers do not render. Roblox's reference is explicit: "As PreRender is client-side, it can only be used in a LocalScript, in a ModuleScript," and for the bind method, "As it is linked to the client's rendering process, BindToRenderStep() can only be called on the client."

There is also a hard warning attached to the render step that people skim past: "All rendering updates will wait until the code in the render step finishes. Make sure that any code called by BindToRenderStep() runs quickly and efficiently; if code takes too long, the experience visuals will be choppy."

That is not a soft suggestion. Code in the render step blocks the frame from being drawn. The task scheduler docs put the guidance bluntly: "Don't connect/bind functions to the render step unless absolutely necessary. Only tasks that must be done after input but before rendering should be done in such a way, like camera movement."

When you genuinely do need the render step — a custom camera rig is the canonical case — the docs are equally clear about which API: "For strict control over order, use BindToRenderStep() instead of PreRender." The reason is the priority argument, which PreRender has no equivalent for. Enum.RenderPriority gives you five documented slots:

RenderPriorityValue
First0
Input100
Camera200
Character300
Last2000

Lower numbers run earlier. A camera that must update after input is read but before the character is drawn goes at Enum.RenderPriority.Camera.Value:

local RunService = game:GetService("RunService")

RunService:BindToRenderStep("OrbitCamera", Enum.RenderPriority.Camera.Value, function(dt)
	local goal = computeCameraCFrame()
	camera.CFrame = camera.CFrame:Lerp(goal, math.min(dt * 12, 1))
end)

-- and when the system shuts down:
RunService:UnbindFromRenderStep("OrbitCamera")

Note that UnbindFromRenderStep takes the same name string you bound with, not a connection object. Forget it and the bind keeps running for the rest of the session — the same class of never-cleaned-up work that causes Roblox memory leaks. Bound render steps are also easy to lose track of because they do not show up as an RBXScriptConnection you can stash in a Maid.

A Roblox MicroProfiler X-ray view of a 1083.247 millisecond capture, with PreRender at 0.576ms containing ProcessInput at 0.392ms, a fireBindTo entry called 27 times, and a highlighted Rebuild Z-order list block at 0.216ms.

PreSimulation vs PostSimulation: the physics sandwich

These two bracket the physics step, and Roblox's task scheduler documentation gives you the whole decision rule in one sentence: "Gameplay logic that affects the physics state should be done in PreSimulation, such as setting the Velocity of parts. In contrast, gameplay logic that relies on or reacts to the physics state should be handled in PostSimulation, such as reading the Position of parts to detect when they enter defined zones."

Write it out as a rule of thumb:

  • Writing to physics → PreSimulation. Applying forces, setting velocities, driving constraints, nudging a CFrame before the solver runs. Do it after physics and the solver has already moved on; your change lands a frame late and looks like input lag.
  • Reading from physics → PostSimulation. Zone checks, "did this part fall below the kill plane," landing detection, anything that needs the settled position of a simulated part. Read it before physics and you are reading last frame's answer.

Getting this backwards is subtle enough to survive testing. A trigger volume checked in PreSimulation works fine at walking speed and starts missing fast-moving parts, because the part crossed the volume entirely inside the physics step you read before. If you want the robust version of that check anyway, prefer real collision detection or a raycast over a per-frame distance comparison.

There is one more phase-specific trap worth memorising, and it is a direct quote: "Motor6D transform changes should be done on the PreSimulation event. If you don't, Animators will overwrite changes on the next frame." Every procedural aiming rig, every head-turn-toward-cursor system, every recoil offset that mysteriously does nothing is a candidate. If you are layering code on top of played animation tracks, that ordering is the whole ballgame — worth reading alongside the Roblox animation guide.

And if you need to touch animation objects themselves — swapping speed, changing priority — PreAnimation is the slot built for it: "Fires every frame, prior to the physics simulation but after rendering."

The 29 millisecond tax on legacy wait

Here is the specific, checkable number. The legacy global wait() is documented as "Yields the current thread until the specified amount of time in seconds have elapsed, with throttling," and its minimum duration is 29 ms. task.wait() is documented as "Yields the current thread without throttling," resuming on the next Heartbeat step.

Do the division. A while true do wait() end loop tops out around 34 iterations per second in the best case, and drops from there under load. A task.wait() loop on a 60 FPS client resumes about 60 times a second. Same code, nearly double the resolution, one character of difference.

task.wait() also returns the actual elapsed time, which is genuinely useful:

-- returns how long it really waited, not what you asked for
local elapsed = task.wait(0.5)

The old globals are not ambiguous about their status either. Each carries the same note: "This method has been superseded by task.wait() and should not be used for future work." Same wording for spawn() and delay() pointing at task.spawn() and task.delay(). Unlike the RunService events — which, again, are not deprecated — the legacy globals genuinely are.

The honest caveat: for a loop that ticks once a second, none of this matters. task.wait(1) and wait(1) will both give you roughly a second. The 29 ms floor only bites when you are asking for something short, which is exactly when people reach for wait() as a pseudo-frame-loop. If you want a frame loop, connect to a frame event. If you want a timer, use task.wait with a real number.

Spawn, defer and delay without the throttling

The rest of the task library maps cleanly onto the resumption points, and the one-line summaries in the docs are precise enough to memorise:

  • task.spawn(fn, ...) — "Calls/resumes a function/coroutine immediately through the engine's scheduler." Immediately means this frame, right now, before the current thread continues.
  • task.defer(fn, ...) — "Calls/resumes a function/coroutine at the end of the current resumption cycle." Same frame, but after everything currently queued. Use it when you need something to happen once the current wave of event handlers has settled.
  • task.delay(n, fn, ...) — "Schedules a function/coroutine to be called/resumed on the next Heartbeat after the given duration (in seconds) has passed, without throttling." The cancellable timer.
  • task.cancel(thread) — kills a scheduled thread before it runs.

That last pairing is the useful one. task.delay returns the thread it scheduled, so you can call it off:

local pending = task.delay(3, function()
	endRound()
end)

-- if the round ends early for another reason:
if pending then
	task.cancel(pending)
	pending = nil
end

One scheduling wrinkle that will eventually matter: Workspace.SignalBehavior controls whether event handlers fire immediately or get deferred to the next resumption point. The docs state that Enum.SignalBehavior.Default "is currently equivalent to Enum.SignalBehavior.Immediate, but will eventually switch to being equivalent to Enum.SignalBehavior.Deferred." Under deferred signals an event that fires another event queues the second one instead of running it inline, with a re-entrancy depth limit the docs put at 10. Code that quietly depends on handlers running inline is code that will change behaviour when that default flips.

Which loop should you actually use

You want to…UseWhy
Move a camera smoothlyBindToRenderStep at RenderPriority.CameraRuns after input, before render, with explicit ordering
Update a HUD that tracks a 3D objectPreRenderLast chance to adjust before the frame draws
Change animation speed or priorityPreAnimationBuilt for animation objects, runs before they step
Apply forces, velocities, Motor6D offsetsPreSimulationPhysics and Animators consume it this frame
Read settled positions, zone checks, landingPostSimulationPhysics has already run
General per-frame server logicHeartbeat / PostSimulationRuns on server and client, end of frame
Wait a fixed amount of timetask.wait(n)No 29 ms throttle, returns real elapsed
Run something after the current handlers settletask.deferEnd of the current resumption cycle
Run something later, cancellablytask.delay + task.cancelReturns the thread
Anything on a serverNot PreRender/BindToRenderStepServers do not render

The default for ordinary gameplay code is Heartbeat or PostSimulation, and that is fine — the docs note it is where most scripts run. Reach for a specific phase when you have a specific reason: you are writing to physics, reading from physics, touching animation, or blocking the render.

Proving it in the MicroProfiler

None of this is worth arguing about in the abstract. Open the MicroProfiler with Ctrl+F6 (Cmd+F6 on Mac) in Studio or the desktop client and look at where your milliseconds go.

A Roblox MicroProfiler flame graph over 246 collected frames, showing a root bar branching into Perform, RenderJob with PreRender and Transparency children, Thread, and Worker containing Heartbeat and Simulation blocks.

The flame graph above aggregates 246 frames, and you can read the whole frame structure off it: Perform (with Scene, RenderView and computeLightingPerform underneath), then RenderJob containing PreRender, then Worker containing Heartbeat and Simulation. Those are the same phase names from the table at the top of this post, drawn to scale. Your connections show up as named bars inside them.

The X-ray capture earlier in this post shows the same thing at finer grain — PreRender at 0.576 ms, ProcessInput at 0.392 ms inside it, a fireBindTo entry called 27 times. Twenty-seven bound render-step callbacks in one frame. Each one is a place a bad decision can cost you the frame.

If you are still building the mental model for how connections, callbacks and threads relate, the Roblox Lua scripting basics guide covers the fundamentals underneath all of this, and ModuleScripts are how you stop copy-pasting the same loop into six different scripts.

Quick Action Checklist

  • Replace while true do wait() end with a real frame event or task.wait
  • Use task.wait over wait everywhere — 29 ms throttle floor versus none
  • Take the deltaTime argument and multiply movement by it, always
  • Remember Stepped passes (time, deltaTime) — two arguments, not one
  • Write to physics in PreSimulation, read from physics in PostSimulation
  • Put Motor6D transform changes in PreSimulation or the Animator overwrites them
  • Never connect PreRender or call BindToRenderStep in a server Script
  • Use BindToRenderStep over PreRender when order matters, with an Enum.RenderPriority value
  • Call UnbindFromRenderStep with the same name string when the system shuts down
  • Keep render-step code short — rendering waits on it
  • Use task.delay plus task.cancel for timers you might need to call off
  • Do not panic-migrate Heartbeat/Stepped/RenderStepped — only Reset() and Run() are deprecated on RunService
  • Confirm your choice in the MicroProfiler with Ctrl+F6 rather than guessing

Frequently Asked Questions

They fire at different points in the frame. RenderStepped fires prior to the frame being rendered and is client-only. Stepped fires prior to the physics simulation. Heartbeat fires after the physics simulation has completed. Roblox also ships modern equivalents: PreRender replaces RenderStepped, PreSimulation replaces Stepped, and PostSimulation replaces Heartbeat, with PreAnimation sitting between rendering and the physics step. Write to physics in PreSimulation and read from physics in PostSimulation.

Keep Reading

Sources & Further Reading

Related Guides

The Roblox Studio interface with the Explorer listing Workspace, Players, ServerScriptService and other services beside the 3D viewport, where memory profiling sessions are run.
🧠Advanced StrategyJul 24, 2026·11 min read

Roblox Memory Leaks: Why Your Game Gets Laggier the Longer a Server Lives

Your game runs great for the first ten minutes and turns to soup by minute forty — same map, same player count, nothing added. That is a memory leak, and on Roblox it is almost always event connections your code never disconnected. Here is how to measure it with the Developer Console and the Stats service, the four leaks that cause 90% of it, and a cleanup pattern that stops the bleeding.

Read article
Roblox Studio in its dark theme showing the 3D viewport, the Explorer tree, and the Properties window where InputContext, InputAction, and InputBinding instances are configured.
🎮Game GuidesJul 31, 2026·13 min read

Roblox Input Action System Guide: Bind Once, Ship Every Platform

Roblox quietly shipped the thing input code has needed for a decade: actions and bindings you configure in the Explorer instead of a LocalScript full of if-statements. One InputAction called CharacterSprint, three InputBindings — LeftShift, ButtonY, an on-screen button — and your script connects to Pressed and Released without ever asking what device the player is holding. Here's the full setup, the five action types, the threshold and Scale numbers that matter, and where UserInputService still earns its keep.

Read article
Roblox Studio in its dark theme showing the 3D viewport, the Explorer tree, and the Properties window where the Tags and Attributes sections used by CollectionService live.
🎮Game GuidesJul 28, 2026·13 min read

Roblox CollectionService Guide: Tag Once, Script Everything

If your Explorer has forty copies of the same killbrick script, you don't have a game — you have forty bugs waiting to disagree with each other. CollectionService fixes that: tag the objects, write one handler, done. Here's the exact pattern, the attribute layer that makes each tagged object configurable, the cleanup step most tutorials skip, and the replication behavior that quietly eats client-side tags.

Read article
The Roblox Studio Animation Editor window with its sections labeled: the media playback controls, the track list of rig body parts on the left, and the keyframe timeline running across the right.
🎮Game GuidesJul 25, 2026·12 min read

Roblox Animation Guide: Make and Play Custom Character Animations

Custom animations are the difference between a Roblox game that feels made and one that feels like a baseplate with scripts. Here's the whole pipeline: rig, keyframes, easing, publishing, and the Animator code that plays it — plus the priority rule that silently eats your attack animation.

Read article
Roblox experiences carousel showing game tiles including Driving Empire, Adopt Me, Field Trip Z, and DOORS fanned around a featured racing game.
🏆Tier ListsMay 29, 2026·11 min read

Best Roblox Games to Play in 2026

Roblox's front page is engagement bait. This is the filtered version: the games with real, sustained player counts and actual staying power, sorted by what you're in the mood for.

Read article
Roblox in-game Buy Item dialog showing an item priced in Robux with a subscription discount applied to the purchase.
🎮Game GuidesMay 29, 2026·11 min read

How to Get Robux Safely (Legit Ways + Scams to Avoid)

There is no free Robux generator. There never was. Here are the actual legit ways to get Robux without overpaying, the earning methods that really work, and the scams that exist purely to steal your account.

Read article