Using Roblox RunService Without Breaking Your Game
RunService is one of those things everyone uses but almost nobody actually understands. It's a client-side service that gives you three events tied to the frame loop. That's basically it. The events are RenderStepped, Stepped, and Heartbeat, and they fire at different points in the frame. Most people just plop a connection into RenderStepped and call it a day. That works until your game needs consistent physics simulation or you try to read state that hasn't been updated yet. RenderStepped fires once per frame, before the physics step. It gets the delta time as a number, and it won't fire faster than the frame rate. If your game runs at 60fps, that's roughly 0.016 seconds between calls. At 30fps, it doubles. This makes it fine for visual updates but dangerous for anything relying on consistent timing. I learned this the hard way when a client-side movement system I wrote had players moving at different speeds depending on their frame rate. The distance was frame-dependent because I multiplied velocity by delta inside RenderStepped without accounting for the fact that delta was inconsistent across machines. The fix was switching to a RunService.Heartbeat-driven physics loop with a fixed timestep accumulator. Took about twenty minutes once I remembered how to actually do it right. Stepped fires once per frame, after the physics step. It also receives two arguments: the render delta and the physics delta. Use this when you need to react to or modify physics results after they resolve. Character movement code in default Roblox uses this pattern because you want your calculations to happen after the engine has already processed collisions and gravity for that frame.
Heartbeat fires once per frame, after all other updates including physics. It gets only the delta time. This is the most commonly misunderstood event. People think it runs after the frame renders, but it doesn't. It runs after the physics step and after RenderStepped, but while the frame is still being processed. This matters because some Roblox functions and property reads behave differently depending on when during the frame they're called.
When to Bind and Unbind
Connecting to RunService events is cheap, but leaving connections alive when they're not needed is a slow leak. I've seen scripts that connect to RenderStepped inside a loop that runs every time a menu opens, and since nobody ever disconnects the old ones, you end up with five copies of the same function firing sixty times per second. The profiler doesn't scream about it immediately. It just gradually eats frames until your average fps drops by a noticeable amount. The pattern that doesn't cause problems is simple. Bind once at startup, store the reference, and disconnect when done. Don't bind inside functions that can be called repeatedly without unbinding first. If you're writing a module that does something per-frame, have it expose a Start() and Stop() method that handle the binding and unbinding internally. That's all it takes to keep things clean.
Get the Full Details

Common Pitfall: Assuming Delta Is Fixed
Delta time is not a constant. It changes every frame. When you're doing anything that involves speed or distance calculations, you must multiply by delta or your behavior will be completely tied to framerate. I once spent an afternoon debugging a client that appeared to teleport objects randomly. The issue was that I was applying a fixed offset per frame instead of scaling by delta. On a 30fps machine the movement was halved and looked wrong. On a 144fps monitor it was nearly five times faster and triggered the teleportation detection. The fix was one multiplication. Another thing nobody warns you about is that RunService events will fire even when the player is not the active focus. If you have a menu open, a dialog visible, or a GUI capturing input, RenderStepped still fires. Your code runs regardless. This is useful but also means you can't rely on user input being available just because you're running inside a RunService loop. Check for focus state explicitly if your logic depends on it.
Performance Reality
RunService connections are cheap but not free. A single RenderStepped connection that does nothing meaningful adds roughly 0.01 to 0.03 milliseconds per frame on a modern machine. That sounds negligible until you have forty of them running across different scripts. At that point you're looking at a full millisecond per frame, maybe two, which is the difference between solid 60fps and stuttery 55fps on average hardware. Profiling is the only way to know for sure what your specific case costs. If you're doing heavy computation inside a RunService callback, consider whether you actually need it there or if you can batch the work. Some people throw pathfinding updates, complex math, or large data processing into RenderStepped because they don't realize how expensive that makes things. Move that work to a coroutine or a custom timer instead. RunService is for updates that need to happen once per frame, not for general purpose background processing.
Getting Started
To use RunService you need a LocalScript because it's a client-side service. Put this in StarterPlayerScripts or wherever your client logic lives. That's the entire pattern. The delta time value you receive is in seconds. Multiply your speeds by it. Disconnect when you no longer need the connection. There isn't much more to it than that, but the subtleties are where things go wrong. It can't help you with server authority problems. RunService runs on the client, so any state you read or modify through it is client-side unless you explicitly send it to the server. It can't replace proper netcode. It also doesn't solve lag. If your server is running slow or your network latency is high, RunService callbacks will still fire at your local framerate, and the values you get will reflect your machine, not the server's reality. This is worth repeating because people try to use it as a crutch for unreliable replication.

There's no way to make RunService fire more often than the frame rate allows. You can't force a fixed sub-step inside RunService itself. If you need deterministic physics that doesn't depend on framerate, you have to build your own accumulator loop using Heartbeat or Stepped. That's straightforward but it means writing the loop yourself, and getting it right on the first try is rare.