---
url: /advanced/performance.md
description: >-
  Measure first, fix algorithmic costs and allocations, and know when not to
  optimize.
---

# Performance

*Written: {{ $frontmatter.date }}*

## Learning objectives

* You can measure before you change anything.
* You can spot nested loops and linear scans in hot paths.
* You can cut per-frame allocations and per-event closures.
* You can read MicroProfiler frame spikes and custom labels.
* You can pick dictionaries, arrays, and caches for each workload.
* You can say no to work that does not matter yet.

## Prerequisites

* [Task and Async](../roblox-fundamentals/task-and-async.md)
* [Tables in Depth](../luau-basics/tables-in-depth.md)
* [Architecture](./architecture.md)

## Measure first

Profile before optimizing. Guesses lie. The slow line is rarely the feared line. Capture a baseline, change one thing, then capture again. Keep the change only when numbers move.

The [MicroProfiler](https://create.roblox.com/docs/en-us/performance-optimization/microprofiler) gives you that baseline. It draws frame time bars on top and task timelines below. Taller bars mean longer frames. Wider timeline blocks mean slower tasks. Find a spike on top, then find its widest child below. Orange frames point at scripts, physics, and animation work. Blue frames point at render work.

Open it with `Ctrl+F6` in Studio and on desktop. Profile on a midrange phone when you can. Phones show pain desktops hide. Save dumps to HTML and compare before and after. Add custom labels with `debug.profilebegin` and `debug.profileend` around suspect code. That pair is documented in the MicroProfiler custom profiles section.

Measuring feels slow when players complain. Skipping it feels fast and breaks random code. Vibe tuning optimizes cold code while hot code burns. Measuring pays off when the fix moves one wide block and frame bars shrink.

## Count work in Luau terms

Count how often a line runs. Setup runs once. Events run sometimes. Per-frame runs sixty times per second. Per-player per-frame multiplies fast. Ten players times sixty frames times one hundred items is sixty thousand checks per second. That math matters before any micro trick.

Nested loops over players times items are the classic trap. The code loops every player, and inside it loops every pickup. Most pairs miss, but the cost remains for all of them. `table.find` in a hot path is the same trap in one call. It scans linearly every time. Ten finds per frame over five hundred entries is five thousand compares per frame.

This wrong version scans everyone for everything every heartbeat.

**ModuleScript in ServerScriptService named SlowAwards**

```luau
--!strict
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local SlowAwards = {}
local pickupsFolder = Workspace:WaitForChild("Pickups")

function SlowAwards.start(): ()
	RunService.Heartbeat:Connect(function(): ()
		local players = Players:GetPlayers()
		local pickups = pickupsFolder:GetChildren()
		for _, player in players do
			local character = player.Character
			if character == nil then
				continue
			end
			local root = character:FindFirstChild("HumanoidRootPart")
			if root == nil then
				continue
			end
			if not root:IsA("BasePart") then
				continue
			end
			for _, pickup in pickups do
				if pickup:IsA("BasePart") then
					local distance = (root.Position - pickup.Position).Magnitude
					if distance < 8 then
						print(player.Name)
						print(pickup.Name)
					end
				end
			end
		end
	end)
end

return SlowAwards
```

It fetches children every frame and loops players times pickups, printing inside the hot loop. Expected behavior: small maps survive, while full servers stutter as both counts grow.

The fixed version flips the structure. It indexes pickups once, checks on a timer instead of every frame, and exits early.

**ModuleScript in ServerScriptService named FastAwards**

```luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local PickIndex = require(ReplicatedStorage:WaitForChild("PickIndex"))

local FastAwards = {}
local checkGap = 0.25
local awardRange = 8

function FastAwards.start(): ()
	task.spawn(function(): ()
		while true do
			task.wait(checkGap)
			local players = Players:GetPlayers()
			if #players == 0 then
				continue
			end
			for _, player in players do
				local character = player.Character
				if character == nil then
					continue
				end
				local root = character:FindFirstChild("HumanoidRootPart")
				if root == nil then
					continue
				end
				if not root:IsA("BasePart") then
					continue
				end
				local hit = PickIndex.near(root.Position, awardRange)
				if hit == nil then
					continue
				end
				print(player.Name)
			end
		end
	end)
end

return FastAwards
```

**ModuleScript in ReplicatedStorage named PickIndex**

```luau
--!strict
local Workspace = game:GetService("Workspace")

local PickIndex = {}
local byName: { [string]: BasePart? } = {}

function PickIndex.rebuild(): ()
	table.clear(byName)
	local folder = Workspace:WaitForChild("Pickups")
	for _, child in folder:GetChildren() do
		if child:IsA("BasePart") then
			byName[child.Name] = child
		end
	end
end

function PickIndex.find(name: unknown): BasePart?
	if typeof(name) ~= "string" then
		return nil
	end
	local found = byName[name]
	if found == nil then
		return nil
	end
	return found
end

function PickIndex.near(origin: Vector3, range: number): BasePart?
	if not math.isfinite(range) then
		return nil
	end
	if range <= 0 or range > 40 then
		return nil
	end
	local best: BasePart? = nil
	local bestDist = range
	for _, part in byName do
		if part ~= nil then
			local dist = (origin - part.Position).Magnitude
			if math.isfinite(dist) and dist < bestDist then
				bestDist = dist
				best = part
			end
		end
	end
	return best
end

return PickIndex
```

The dictionary rebuilds on demand, not per frame. The scan covers one compact map four times per second, not sixty. Expected behavior: award checks cost players times cached parts at quarter-second cadence, and renames need one rebuild call.

Caches go stale. Rebuild on child added and removed. Always-fresh scans stay correct and slow. Change the cache when stale hits cause bug reports. Shorten the rebuild gap or hook ancestry events.

## Cut allocations

Allocations cost twice: creation and collection. Per-frame tables and per-event closures both trigger the collector at the worst time. The result feels like random hitches.

Table creation per frame is the first leak. The code builds a result table sixty times per second, keeps only one row, and throws the rest away. Closure per event is the second leak. A new anonymous function per button and heartbeat means the collector sweeps them all.

This wrong version allocates in the hot path.

**LocalScript in StarterPlayerScripts named WobblyHud**

```luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local player = Players.LocalPlayer
if player == nil then
	return
end
local gui = player:WaitForChild("PlayerGui")
local screen = gui:WaitForChild("HudScreen")
local bar = screen:WaitForChild("HealthBar") :: Frame
local character = player.Character
if character == nil then
	return
end
local Humanoid = character:WaitForChild("Humanoid") :: Humanoid

Humanoid.HealthChanged:Connect(function(next: number): ()
	local style = { fill = next / 100, flash = next < 30 } :: { fill: number, flash: boolean }
	local scale = style.fill
	if style.flash then
		scale = 1
	end
	bar.Size = UDim2.fromScale(scale, 1)
end)
```

It builds a `style` table on every health tick but reads two fields once. Expected behavior: bars update, while the collector sweeps one dead table per tick.

The fixed version hoists constants and reuses locals.

**LocalScript in StarterPlayerScripts named SteadyHud**

```luau
--!strict
local Players = game:GetService("Players")

local player = Players.LocalPlayer
if player == nil then
	return
end
local gui = player:WaitForChild("PlayerGui")
local screen = gui:WaitForChild("HudScreen")
local bar = screen:WaitForChild("HealthBar") :: Frame
local character = player.Character
if character == nil then
	return
end
local humanoid = character:WaitForChild("Humanoid") :: Humanoid
local lowHealth = 30
local maxHealth = 100

local function applyHealth(next: number): ()
	if not math.isfinite(next) then
		return
	end
	local scale = next / maxHealth
	if next < lowHealth then
		scale = 1
	end
	bar.Size = UDim2.fromScale(scale, 1)
end

humanoid.HealthChanged:Connect(applyHealth)
```

It binds one named function, hoists thresholds, and allocates nothing per tick. Expected behavior: bars update with zero tables per event and one connection total.

Apply the same rule to loops. Hoist `Vector3` temps. Reuse RaycastParams. Set `Parent` last once outside the loop. Avoid `table.find` inside loops by indexing with a dictionary first.

## Read MicroProfiler like a map

Start at the frame bars. Click the tallest bar. Pan to its widest child. Fix that child. Ignore thin slivers until wide blocks shrink. Parent blocks never beat the sum of their children.

Add labels to your own code. Wrap load, save, and award passes. Keep label names stable so dumps compare cleanly.

**ModuleScript in ServerScriptService named ProfiledAwards**

```luau
--!strict
local Players = game:GetService("Players")

local ProfiledAwards = {}

function ProfiledAwards.grantAll(amount: number): ()
	if not math.isfinite(amount) then
		return
	end
	if amount < 1 or amount > 5 then
		return
	end
	debug.profilebegin("GrantAll")
	for _, player in Players:GetPlayers() do
		local stats = player:FindFirstChild("leaderstats")
		if stats == nil then
			continue
		end
		local coins = stats:FindFirstChild("Coins")
		if coins ~= nil and coins:IsA("IntValue") then
			coins.Value += math.floor(amount)
		end
	end
	debug.profileend()
end

return ProfiledAwards
```

It labels one grant pass and guards bounds before the label. Expected behavior: MicroProfiler shows one `GrantAll` block per call with its true cost, and bad amounts never open a label.

Profile the server separately when client bars look clean but play feels slow. Open the Developer Console MicroProfiler tab and capture server frames. Server spikes mean Services burn. Client spikes mean rendering or Controllers burn.

## Pick structures for workloads

Match shape to question. Lookup by id wants a dictionary. Order or queue wants an array. Both wants both: one dictionary for find, one array for order. Spatial nearness wants buckets, not full scans.

Dictionaries answer `find` in one step. Arrays answer `next` in order. Mixed tables with both string and numeric keys confuse readers. Keep one shape per table.

**ModuleScript in ReplicatedStorage named Roster**

```luau
--!strict
export type Entry = {
	userId: number,
	team: string,
}

local Roster = {}
local byId: { [number]: Entry } = {}
local order: { number } = {}

function Roster.add(userId: number, team: string): ()
	if userId <= 0 then
		return
	end
	if #team < 2 then
		return
	end
	if byId[userId] ~= nil then
		return
	end
	byId[userId] = { userId = userId, team = team }
	table.insert(order, userId)
end

function Roster.get(userId: number): Entry?
	return byId[userId]
end

function Roster.list(): { Entry }
	local out: { Entry } = {}
	for _, id in order do
		local entry = byId[id]
		if entry ~= nil then
			table.insert(out, entry)
		end
	end
	return out
end

function Roster.remove(userId: number): ()
	byId[userId] = nil
	local kept: { number } = {}
	for _, id in order do
		if id ~= userId then
			table.insert(kept, id)
		end
	end
	table.clear(order)
	for _, id in kept do
		table.insert(order, id)
	end
end

return Roster
```

It writes both structures on add, reads by id in one step, and lists in join order. Expected behavior: lookups stay fast as counts grow, lists stay stable, and removes clear both sides.

Shrink the candidate set before measuring. Check zone first, then bucket, then measure distance last. Never measure the whole map to find one neighbor. For large worlds, partition by region or cell and only scan the local cell plus neighbors.

## Cache expensive calls

Some calls cost more than they look. `GetPlayers`, `GetChildren`, `FindFirstChild`, pathfinds, and raycasts all touch live trees. Call them rarely. Store the result. Invalidate on change.

Cache with a timestamp or a version. Serve the cache while fresh. Refresh when stale. Never cache per-player coins as truth. Cache display copies and re-derive authority on use.

**ModuleScript in ServerScriptService named PriceCache**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Catalog = require(ReplicatedStorage:WaitForChild("ShopCatalog"))

local PriceCache = {}
local cached: { [string]: number } = {}
local freshUntil = 0
local cacheSeconds = 5

function PriceCache.price(item: unknown): number?
	if typeof(item) ~= "string" then
		return nil
	end
	local now = os.clock()
	if now > freshUntil then
		table.clear(cached)
		for name, entry in Catalog do
			local value = (entry :: any).price
			if typeof(value) == "number" and math.isfinite(value) then
				cached[name] = math.floor(value)
			end
		end
		freshUntil = now + cacheSeconds
	end
	return cached[item]
end

return PriceCache
```

It refreshes every five seconds and validates each price before caching. Expected behavior: hot shop reads hit memory, catalog edits apply within five seconds, and bad items return nil.

Short windows stay fresh and call often. Long windows call rarely and lie longer. Start at five seconds for tuning. Drop to zero for money math. Money always reads truth.

## Know when not to optimize

Skip work that runs rarely. One-time setup can be plain. Admin commands can be plain. Error paths can be plain. Optimize hot paths first: per-frame, per-hit, per-purchase. Leave cold code readable.

Skip clever code that hides bugs. Bit packing saves bytes and loses juniors. Custom allocators save microseconds and lose weekends. Prefer clear loops plus better structure. Take the clever path only when MicroProfiler proves the clear path misses budget on target phones.

Watch for three stop signs. Frames already hit sixty on the weakest device. Changes move code but not bars. Complexity grows faster than savings. When any one appears, stop and ship readability.

Revisit when counts change. Ten players become fifty. One hundred parts become five thousand. A new map doubles raycasts. Re-measure on those milestones. Until then, build features.

## Common mistakes

**Mistake 1: scanning with table.find in a hot loop.**

This wrong example linearly scans every swing.

**ModuleScript in ServerScriptService named SlowRoles**

```luau
--!strict
local SlowRoles = {}
local admins: { string } = { "Mira", "Theo", "June" }

function SlowRoles.isAdmin(name: string): boolean
	local found = table.find(admins, name)
	if found == nil then
		return false
	end
	return true
end

return SlowRoles
```

It scans three names per call today. It scans three thousand after launch. Fix it with a dictionary set built once.

**ModuleScript in ReplicatedStorage named FastRoles**

```luau
--!strict
local FastRoles = {}
local adminSet: { [string]: boolean } = {
	Mira = true,
	Theo = true,
	June = true,
}
table.freeze(adminSet)

function FastRoles.isAdmin(name: unknown): boolean
	if typeof(name) ~= "string" then
		return false
	end
	return adminSet[name] == true
end

return FastRoles
```

Expected behavior: admin checks cost one lookup, and the frozen set blocks accidental edits.

**Mistake 2: allocating per frame.**

This wrong example builds a table sixty times per second.

**ModuleScript in ServerScriptService named SlowPulse**

```luau
--!strict
local SlowPulse = {}

function SlowPulse.tick(dt: number): ()
	local frame = { dt = dt, at = os.clock() } :: { dt: number, at: number }
	print(frame.dt)
end

return SlowPulse
```

It keeps nothing from the table except `dt`. Fix it by passing numbers directly.

**ModuleScript in ServerScriptService named FastPulse**

```luau
--!strict
local FastPulse = {}

function FastPulse.tick(dt: number): ()
	if not math.isfinite(dt) then
		return
	end
	if dt < 0 or dt > 1 then
		return
	end
	print(dt)
end

return FastPulse
```

Expected behavior: ticks print with zero allocations and finite bounds.

**Mistake 3: optimizing cold setup while hot awards burn.**

This wrong example micro-tunes a one-time sort.

**ModuleScript in ReplicatedStorage named ColdSort**

```luau
--!strict
local ColdSort = {}

function ColdSort.names(): { string }
	local list = { "Bread", "Apple", "Cake" }
	table.sort(list)
	return list
end

return ColdSort
```

It sorts three strings once. No player feels it. Fix it by leaving setup plain and moving effort to the per-frame award scan with PickIndex.

## Exercises

1. Replace a players-times-items nest with an indexed lookup.
2. Remove a per-frame allocation from a HUD update.
3. Add one MicroProfiler label and compare two runs.

::: details Solution 1
**ModuleScript in ReplicatedStorage named ZoneIndex**

```luau
--!strict
local ZoneIndex = {}
local zoneOf: { [string]: string } = {
	Bush1 = "North",
	Bush2 = "North",
	Bush3 = "South",
}
table.freeze(zoneOf)

function ZoneIndex.sameZone(a: unknown, b: unknown): boolean
	if typeof(a) ~= "string" then
		return false
	end
	if typeof(b) ~= "string" then
		return false
	end
	return zoneOf[a] == zoneOf[b]
end

return ZoneIndex
```

**ModuleScript in ServerScriptService named ZoneAwards**

```luau
--!strict
local Players = game:GetService("Players")

local ZoneAwards = {}

function ZoneAwards.countNorth(): number
	local total = 0
	for _, player in Players:GetPlayers() do
		local zone = player:GetAttribute("Zone")
		if typeof(zone) ~= "string" then
			continue
		end
		if zone == "North" then
			total += 1
		end
	end
	return total
end

return ZoneAwards
```

Expected behavior: zone checks cost one attribute read plus one compare per player, with no nested item loop.
:::

::: details Solution 2
**LocalScript in StarterPlayerScripts named LeanBar**

```luau
--!strict
local Players = game:GetService("Players")

local player = Players.LocalPlayer
if player == nil then
	return
end
local gui = player:WaitForChild("PlayerGui")
local screen = gui:WaitForChild("HudScreen")
local bar = screen:WaitForChild("ManaBar") :: Frame
local character = player.Character
if character == nil then
	return
end
local humanoid = character:WaitForChild("Humanoid") :: Humanoid

local function applyMana(): ()
	bar.Size = UDim2.fromScale(humanoid.Health / 100, 1)
end

humanoid.HealthChanged:Connect(applyMana)
```

Expected behavior: mana bars update with one shared function and zero tables per tick.
:::

::: details Solution 3
**ModuleScript in ServerScriptService named LabeledShop**

```luau
--!strict
local LabeledShop = {}

function LabeledShop.total(cart: { number }): number
	debug.profilebegin("ShopTotal")
	local sum = 0
	for _, price in cart do
		if typeof(price) ~= "number" then
			continue
		end
		if not math.isfinite(price) then
			continue
		end
		sum += math.floor(price)
	end
	debug.profileend()
	return sum
end

return LabeledShop
```

Expected behavior: MicroProfiler shows one `ShopTotal` block per call, and you compare widths before and after your fix.
:::

## The MicroProfiler workflow

[MicroProfiler](https://create.roblox.com/docs/performance-optimization/microprofiler) focuses entirely on frame time, not frame rate: find a spike (a tall bar), then read the timeline to learn which task caused it. Open it with `Ctrl+F6` (`Cmd+F6`) in Studio or the desktop client, or over the network from a mobile device on the same LAN. Orange bars mean worker threads (scripts, physics, pathfinding) outran the render thread; blue means the render thread (density, lighting, movement) dominates; red adds GPU wait over 2.5 ms. Pause a capture with `Ctrl+P`, save dumps to HTML for sharing, and capture the server (max 60 frames) from the Developer Console's MicroProfiler tab. Profile on a midrange mobile device first, desktops with FPS caps hide 4× slowdowns.

```luau
--!strict
local RunService = game:GetService("RunService")

local function onPreSimulation(): ()
	debug.profilebegin("ShopTick")
	local total = 0
	for _, price in { 3, 7, 9 } do
		total += price
	end
	print(total)
	debug.profileend()
end
RunService.PreSimulation:Connect(onPreSimulation)
```

Wrap suspect code in `debug.profilebegin`/`debug.profileend` to print a custom label on the timeline, fix the worst child task first (a parent can never be shorter than its children), and re-capture to prove the spike shrank. For streamed places, remember tags and properties re-sync from the server when instances stream back in, measure with streaming on, on device, before calling a scene fast.

## Summary

Measure with MicroProfiler before touching code. Open it with Ctrl+F6, read orange vs blue bars, and label suspect code with debug.profilebegin. Kill nested loops and linear scans in hot paths. Hoist tables and closures out of per-frame work. Pick dictionaries for find, arrays for order, and zones for nearness. Cache costly reads with short windows. Leave cold code alone. Next, build it all in [Service Controller System](../projects/service-controller-system.md).
