Appearance
Performance
Written: 2026-10-14
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
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 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 SlowAwardsIt 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 FastAwardsModuleScript 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 PickIndexThe 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 ProfiledAwardsIt 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 RosterIt 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 PriceCacheIt 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 SlowRolesIt 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 FastRolesExpected 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 SlowPulseIt 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 FastPulseExpected 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 ColdSortIt 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
- Replace a players-times-items nest with an indexed lookup.
- Remove a per-frame allocation from a HUD update.
- Add one MicroProfiler label and compare two runs.
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 ZoneIndexModuleScript 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 ZoneAwardsExpected behavior: zone checks cost one attribute read plus one compare per player, with no nested item loop.
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.
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 LabeledShopExpected behavior: MicroProfiler shows one ShopTotal block per call, and you compare widths before and after your fix.
The MicroProfiler workflow
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.