Roblox Performance Optimisation: Reducing Lag and Improving Frame Rate

TL;DR - Key Takeaways
- • Roblox is a mobile-first platform; the majority of players are on phones and tablets with strict frame budgets.
- • Use the MicroProfiler (Ctrl+F6) to find where your frame time is being spent before making any optimisations.
- • Every Part, Union, and MeshPart in Workspace has a render and physics cost; keep instance counts low and use LOD where possible.
- • Replace polling loops with event-driven code; busy loops are the single most common cause of avoidable CPU overhead in Roblox games.
- • Batch RemoteEvent traffic and send only deltas; never fire to all clients every frame.
- • Clean up connections and instances properly to prevent memory leaks that compound over long sessions.
- • Profile, fix the biggest item, profile again; never optimise by intuition.
Performance is the thing that most Roblox developers deal with reactively. The game ships, players start complaining about lag, and then begins the frantic process of trying to work out what is wrong with a live product that already has users. I have been on both sides of that situation: the developer who built something slow and had to fix it under pressure, and the engineer brought in to rescue a game that was already struggling. Neither experience is enjoyable. This guide is about avoiding both.
What I am going to give you here is the actual process we follow at Santoz Studios when we either audit an existing game or approach a new build with performance in mind from the start. It is not a list of micro-optimisations to copy blindly. It is a way of thinking about performance that will serve you across every project you work on.
Why Performance Matters on Roblox Specifically
Roblox is a mobile-first platform. This is not a secondary consideration; it is the defining constraint of every performance decision you make. According to Roblox's own published statistics, the majority of players access the platform on a phone or tablet, and a significant proportion of those devices are mid-range or low-end Android handsets. These are not machines with dedicated GPUs and 16 GB of RAM. They are devices with integrated graphics, limited thermal headroom, and battery pressure that causes the OS to throttle CPU performance after sustained load.
What this means in practice: a game that runs smoothly at 60 fps on your development machine may drop to 20 fps on the median device your player base is actually using. At 20 fps, input feels unresponsive, animations look choppy, and the game is simply less enjoyable regardless of how good the design is. Players do not articulate this as a frame rate problem. They say the game is "laggy" or "bad" and they leave. Your concurrent player count and session length metrics suffer directly from poor performance, which means your revenue suffers too.
There is also a less obvious reason performance matters: Roblox's discovery algorithm favours games with strong engagement metrics. Session time, Day 1 retention, Day 7 retention: all of these are hurt by poor performance. A game that runs badly on the average device has structurally worse metrics than an identical game that runs well, and those metrics compound over time into less organic discovery. Performance is not just a quality-of-life issue; it directly affects the growth trajectory of your game.
Understanding the Frame Budget
At 60 frames per second, the engine has exactly 16.67 milliseconds to complete everything required for one frame: run scripts, simulate physics, process network traffic, and render the scene. This is your frame budget. Every system in your game is spending from this budget, and when the total exceeds 16.67ms, the frame rate drops.
For a mobile device targeting 30 fps (which is a more realistic ceiling for many low-end phones), the budget is 33.33ms. This sounds more generous, but you must also account for the fact that mobile CPUs are slower at single-threaded tasks than desktop CPUs. A script that uses 5ms on your PC might use 12ms on a mid-range phone. The nominal budget is larger, but the actual throughput is lower.
The frame budget is divided between four primary categories:
- Script execution (Heartbeat/Stepped): all your Luau code running each frame.
- Physics simulation: the engine calculating collisions, forces, and constraint updates.
- Rendering: draw calls, mesh uploads, texture sampling, lighting calculations.
- Network: serialising and transmitting updates to clients, receiving and applying server updates.
In most games I have audited, the culprit is either script execution or rendering, with physics being a distant third. Network issues tend to manifest as rubber-banding and desync rather than low frame rate. Understanding which category is eating your budget is the whole point of the MicroProfiler.
The MicroProfiler in Practice
The MicroProfiler is Roblox Studio's built-in performance analysis tool. You open it with Ctrl+F6 (or Cmd+F6 on Mac) while your game is running in Studio. It shows a timeline of every frame, broken into coloured bars that represent individual tasks. The horizontal axis is time; the vertical axis is a hierarchy of tasks and subtasks. A frame that overruns its budget appears as a bar that is wider than its neighbours.
The key task names to know:
- Heartbeat: this is where your server-side and client-side script code runs. If this bar is large, your scripts are expensive. Expand it to see which specific script or connection is consuming the most time.
- Render: everything to do with drawing the scene. Large Render bars typically mean too many draw calls (too many separate meshes), expensive lighting, or high triangle counts.
- physicsStepped: physics simulation. Large physics bars often indicate too many unanchored parts, complex mesh collisions, or poorly configured constraints.
- Network (Receive/Send): serialising and deserialising replicated data. Large network bars indicate you are replicating too much, too frequently.
- waitingForRenderThread: the CPU waiting for the GPU to finish rendering. If this is large, you are GPU-bound: your scene has too many triangles or too many draw calls for the device's graphics hardware.
The workflow is straightforward: open the MicroProfiler, let it run for ten to twenty seconds while the game is in a representative state (players present, action happening), then pause it with Ctrl+P. Find the widest frame bars and expand them to trace the source of the overhead. The MicroProfiler also has a "detailed" mode (press Ctrl+F6 a second time in Studio) that shows individual function-level timing for Luau code. This is invaluable for identifying which specific function is expensive.
One thing to keep in mind: always profile in the environment that matches your production conditions. Profiling in Studio with one client and no actual network traffic gives you useful data about script performance, but it will not show you network or render issues that only appear with ten or twenty players connected simultaneously. Use a live game or a private test server with multiple connected clients for a realistic picture.
Instance Budgets: Every Part Has a Cost
Roblox renders your Workspace as a collection of instances, and every instance has a cost. The render cost comes from the number of draw calls the engine has to issue to the GPU: one per unique mesh, roughly speaking. The physics cost comes from the number of collidable primitives in the simulation. The memory cost comes from geometry, textures, and material data loaded for each instance. These costs are independent, and they all compound as your instance count rises.
As a rough heuristic: a well-optimised Roblox game targets fewer than 5,000 instances in Workspace during active play. This is not a hard limit, but it is the range where you are unlikely to run into render or physics budget problems on mid-range mobile hardware. Games with 15,000 or 20,000 Workspace instances are almost always struggling on mobile, and the fix is always the same: reduce the count.
The practical strategies for reducing instance counts:
- Use Unions sparingly but strategically. Merging multiple Parts into a Union reduces your Workspace Part count. However, Unions have complex collision geometry that is more expensive for the physics engine than simple primitives. Use Unions for decorative, non-collidable geometry and set their collision fidelity to
Boxor disable collision entirely where touch detection is not needed. - Prefer MeshParts for complex shapes. A custom MeshPart with a low-poly collision mesh is more efficient than an equivalent shape assembled from ten separate Parts. It is one draw call instead of ten, and you can control the collision geometry precisely.
- Stream instances with Level of Detail (LOD). Enable Instance Streaming in your game's settings. This tells Roblox to only load instances near the player, dramatically reducing the active instance count in large worlds. Pair this with LOD meshes for distant objects: high-detail meshes up close, simple box representations in the distance.
- Anchor everything that does not need to move. Anchored parts are excluded from physics simulation. If a Part in your world never moves, it should be anchored. This is one of the highest-return, lowest-effort optimisations available to you.
- Destroy instances you no longer need. Projectiles that have hit their target, effects that have finished playing, NPCs that have despawned: if they are still in Workspace, they still have a cost. Clean them up explicitly rather than waiting for garbage collection.
Script Performance: Common Luau Anti-Patterns
Scripts are where most developers have direct control over performance, and where most of the avoidable overhead lives. The Roblox engine's rendering and physics systems are generally well-optimised by Roblox itself; your Luau code is entirely your responsibility.
Busy loops with polling. The most common performance problem I encounter in audits. A while true do ... task.wait(0.1) end loop that checks a condition every 100ms runs 10 times per second, every second, regardless of whether anything has changed. If you have twenty of these running across your game (and in a complex game, you easily can), you have 200 spurious function calls per second. Use events: connect to Changed, HealthChanged, Touched, or a custom BindableEvent. Fire when the state changes, not on a timer.
-- BAD: polling a value every frame wastes CPU on every tick
RunService.Heartbeat:Connect(function()
if player.leaderstats.Level.Value >= 10 then
unlockFeature()
end
end)
-- GOOD: connect once to the Changed signal, fires only when the value changes
player.leaderstats.Level.Changed:Connect(function(newLevel)
if newLevel >= 10 then
unlockFeature()
end
end)
Excessive table lookups in tight loops. Every indexing operation in Luau has a small cost. In a loop that runs every frame, even small costs accumulate. Cache table lookups that are used repeatedly within a loop by assigning them to a local variable before the loop starts.
-- BAD: re-indexing playerData table on every iteration
RunService.Heartbeat:Connect(function(dt)
for _, player in ipairs(Players:GetPlayers()) do
if playerData[player.UserId] and playerData[player.UserId].isAlive then
playerData[player.UserId].timeSurvived += dt
end
end
end)
-- GOOD: cache the profile reference inside the loop
RunService.Heartbeat:Connect(function(dt)
for _, player in ipairs(Players:GetPlayers()) do
local profile = playerData[player.UserId]
if profile and profile.isAlive then
profile.timeSurvived += dt
end
end
end)
String concatenation inside loops. In Luau, every .. concatenation creates a new string object. Concatenating inside a loop with thousands of iterations generates thousands of short-lived string allocations and puts pressure on the garbage collector. Use table.concat to build strings from parts, or use string.format for structured output.
-- BAD: concatenation inside a loop creates many intermediate strings
local result = ""
for i = 1, 1000 do
result = result .. tostring(i) .. ", "
end
-- GOOD: collect parts and join once at the end
local parts = {}
for i = 1, 1000 do
parts[i] = tostring(i)
end
local result = table.concat(parts, ", ")
Connecting to RunService.Heartbeat for infrequent work. RunService.Heartbeat fires every frame (up to 60 times per second). If your work does not need to run that frequently, do not put it there. A leaderboard update that checks player scores can run every 5 seconds. A damage-over-time tick can run every 0.5 seconds. Use task.delay and task.spawn with appropriate intervals rather than running everything at frame rate.
Network Optimisation: Reducing RemoteEvent Traffic
Network overhead in Roblox games comes from two sources: property replication (automatic, when you change a property on a replicated instance) and explicit RemoteEvent or RemoteFunction calls. Both have bandwidth costs. When you are sending too much, too frequently, players experience rubber-banding (the client predicts a position, the server corrects it, the correction is delayed by congestion), UI values that lag behind the server state, and in extreme cases, complete disconnection due to Roblox's server-side bandwidth throttling.
Event batching. Rather than firing a separate RemoteEvent for every individual state change, collect changes during a frame and send them as a single payload at the end of the frame (or on a 100ms tick). This reduces the number of individual network packets significantly, since each packet has a fixed overhead beyond the payload size itself.
-- BAD: firing one RemoteEvent per stat update (many per second)
local function updateUI(player, statName, newValue)
UIUpdateEvent:FireClient(player, statName, newValue)
end
-- GOOD: batch pending updates, flush once per tick
local pendingUpdates: {[Player]: {[string]: any}} = {}
local function queueUpdate(player: Player, statName: string, newValue: any)
if not pendingUpdates[player] then
pendingUpdates[player] = {}
end
pendingUpdates[player][statName] = newValue
end
-- Flush at most 10 times per second
task.spawn(function()
while true do
task.wait(0.1)
for player, updates in pairs(pendingUpdates) do
if player.Parent then -- still in game
UIBatchUpdateEvent:FireClient(player, updates)
end
end
table.clear(pendingUpdates)
end
end)
Send only deltas. Do not send the full state of a system every tick; send only the values that have changed since the last send. A UI update event that sends all twenty of a player's stats every 100ms is sending nineteen values that have not changed. Track what has been acknowledged by the client and send only the diff.
Never use FireAllClients in tight loops. RemoteEvent:FireAllClients() sends data to every connected player simultaneously. Doing this every frame, or even every second, in a large server generates bandwidth proportional to your player count. A server with 50 players receiving a FireAllClients every frame at 60 fps is processing 3,000 individual send operations per second. Use FireAllClients only for low-frequency, high-value events: round start, map change, a global announcement. For per-player data, use FireClient and send only to the player whose data has changed.
Memory Management: Cleaning Up Properly
Memory leaks in Roblox games are subtle and compound over time. A game that runs fine for the first ten minutes of a session may become increasingly sluggish after thirty, as leaked connections and uncleaned instances accumulate. Players who have long sessions (your best players, the ones you most want to retain) are the ones who are most affected by memory leaks.
The most common source of memory leaks is event connections that are never disconnected. Every time you call :Connect() on an event, Roblox holds a reference to the callback function and everything it captures in its closure. If the instance the callback references has been destroyed but the connection is still live, the callback is never garbage collected. Over many player joins and leaves, or many spawned objects, these orphaned connections accumulate.
-- BAD: connection is never cleaned up when the character is removed
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(character)
local humanoid = character:WaitForChild("Humanoid")
humanoid.Died:Connect(function()
handlePlayerDeath(player)
end)
-- This connection leaks on every respawn
end)
end)
-- GOOD: store the connection and disconnect it when the character is removed
Players.PlayerAdded:Connect(function(player)
local connections: {RBXScriptConnection} = {}
player.CharacterAdded:Connect(function(character)
-- Clean up previous character's connections
for _, conn in ipairs(connections) do
conn:Disconnect()
end
table.clear(connections)
local humanoid = character:WaitForChild("Humanoid")
local diedConn = humanoid.Died:Connect(function()
handlePlayerDeath(player)
end)
table.insert(connections, diedConn)
end)
-- Clean up all connections when the player leaves
Players.PlayerRemoving:Connect(function(leavingPlayer)
if leavingPlayer == player then
for _, conn in ipairs(connections) do
conn:Disconnect()
end
table.clear(connections)
end
end)
end)
Object pools for frequently spawned instances. If your game spawns and destroys the same type of object repeatedly (bullets, collectibles, particles, NPCs), creating and destroying actual Roblox instances each time is expensive. Instance creation involves memory allocation, replication overhead, and physics initialisation. Instead, maintain a pool of pre-created instances that you activate and deactivate rather than creating and destroying.
-- ModuleScript: simple object pool for projectiles
local ObjectPool = {}
ObjectPool.__index = ObjectPool
function ObjectPool.new(template: BasePart, initialSize: number)
local self = setmetatable({}, ObjectPool)
self._template = template
self._available = {}
for i = 1, initialSize do
local clone = template:Clone()
clone.Parent = workspace
clone.Anchored = true
clone.CanCollide = false
clone.Transparency = 1 -- hidden when in pool
table.insert(self._available, clone)
end
return self
end
function ObjectPool:acquire(): BasePart
local obj = table.remove(self._available)
if not obj then
obj = self._template:Clone()
obj.Parent = workspace
end
obj.Transparency = 0
obj.Anchored = false
obj.CanCollide = true
return obj
end
function ObjectPool:release(obj: BasePart)
obj.Anchored = true
obj.CanCollide = false
obj.Transparency = 1
obj.Velocity = Vector3.zero
table.insert(self._available, obj)
end
return ObjectPool
Clean up on PlayerRemoving, not just on game end. A Roblox server can run for many hours with players cycling in and out. Every player who joins and leaves without their associated data, connections, and instances being cleaned up contributes to a slow leak that accumulates across the server's lifetime. Always have a Players.PlayerRemoving handler that explicitly clears everything associated with that player: their data table entries, any persistent GUI elements in their character, any connections that reference them.
How to Approach a Performance Audit Systematically
The single most important rule of performance work is: profile first, fix the biggest item, profile again. Never guess. Never fix something because it looks suspicious. Fix what the data tells you is the largest contributor to your problem, verify the fix with a new profile, and then proceed to the next item.
The reason this matters is that performance problems are rarely where you think they are. I have audited games where the developer was convinced the problem was script performance, and the MicroProfiler showed the scripts were fine; the problem was 40,000 Parts in Workspace with no LOD. I have audited games where the developer was convinced the problem was draw calls, and the issue was a single RunService.Heartbeat connection that was doing a full scan of every player's inventory every frame. Guessing is not just inefficient; it is often wrong.
A structured performance audit looks like this:
- Step 1: Establish a baseline. Open the MicroProfiler in a representative session. Record the average frame time and identify which category (scripts, render, physics, network) is consuming the most time.
- Step 2: Find the biggest offender within that category. Expand the relevant bar in the MicroProfiler to its sub-tasks. Find the single most expensive item. This is your first fix.
- Step 3: Fix it, then profile again. Implement the fix. Open a new MicroProfiler session in the same conditions. Compare the new average frame time to your baseline. Confirm the fix had the expected effect.
- Step 4: Repeat until you hit your target. Move to the next biggest item and repeat. Stop when your frame time is within budget for your target device, not when you run out of ideas.
- Step 5: Test on hardware representative of your player base. If possible, test on a mid-range Android device or use Roblox's built-in device emulator. A game that runs at 60 fps in Studio is not guaranteed to run at 30 fps on the average player's phone.
One last observation from doing this work professionally: the games that are hardest to optimise are the ones built without any performance consideration from the start. Refactoring a polling architecture into an event-driven one after the fact, across dozens of scripts written by multiple developers, is weeks of careful work. Building event-driven from the start costs almost nothing extra. Architectural decisions you make at the beginning of a project either accrue as savings or compound as debt throughout the entire development cycle.
This connects directly to how you structure your code at the architectural level. Clean, well-separated code is not just easier to maintain; it is easier to profile, easier to isolate for testing, and structurally more efficient because responsibilities are clearly separated and nothing is doing more than one job per frame cycle. If you want a deeper look at how to build that kind of architecture from the ground up, our Roblox client-server architecture guide covers exactly that.
If you have a Roblox game that is suffering from performance issues, or if you are starting a new project and want to make sure performance is built in from day one, the team at Santoz Studios has done this work across more than 45 shipped games. Get in touch with us and we can help you identify exactly where your frame budget is going and how to get it back.
Arya Harshwardhan
Lead Developer, Santoz Studios
Arya Harshwardhan is Lead Developer at Santoz Studios, specialising in Luau architecture, performance engineering, and exploit-resistant game systems. He has shipped over 45 Roblox games and regularly works on performance rescue projects for studios.

