roblox5 min read|2026-06-01

Luau Performance in Roblox

Native codegen, type inference, table pre-allocation, and the bottlenecks that show up in Roblox game scripts.

robloxluauoptimizationgamedev

Luau and Lua

Roblox's Luau started as a Lua 5.1 fork and has grown into its own language runtime. It has a custom bytecode VM, optional type annotations, native codegen, and a generational garbage collector. Understanding how these pieces interact helps you write faster game scripts.

The native codegen pipeline

When you enable --!native at the top of a script, Luau compiles hot functions to native machine code. This can give 2-5x speedups on computation-heavy code. Roblox API calls and event-heavy scripts often remain limited by other parts of the engine.

Native codegen works best on:

  • Math-heavy loops (physics calculations, noise generation)
  • Table iteration with numeric keys
  • String manipulation in tight loops

Native codegen has less impact on:

  • Roblox API calls (these cross the Lua/C++ boundary regardless)
  • Code that's already bottlenecked on API rate limits
  • Short functions that run once per event
--!native
--!strict

local function compute_damage(
    base: number,
    multiplier: number,
    armor: number,
    resistances: {number}
): number
    local effective = base * multiplier
    for _, res in resistances do
        effective = effective * (1 - res)
    end
    return math.max(0, effective - armor)
end

With --!native and --!strict, Luau can type-check base, multiplier, and armor at compile time and generate tight native code for the arithmetic. Missing type annotations usually mean more runtime type checks.

Table allocation patterns

Tables are the most important data structure in Luau and a common source of performance problems. Every table allocation triggers GC tracking, and every resize copies data.

-- Table grows incrementally and triggers multiple resizes
local results = {}
for i = 1, 1000 do
    results[i] = compute(i)
end

-- Pre-allocate the array part
local results = table.create(1000)
for i = 1, 1000 do
    results[i] = compute(i)
end

table.create(n) pre-allocates n array slots. This avoids the geometric growth pattern where the table doubles in size when it runs out of space. For 1000 elements, the bad version triggers about 10 resizes (1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024). Each resize allocates new memory and copies everything.

The hash part and array part

Luau tables have two internal storage areas: the array part (for integer keys 1..n) and the hash part (for everything else). Using sequential integer keys starting at 1 keeps data in the array part, which is a simple C array with O(1) indexed access.

-- Array part: fast sequential access
local positions = table.create(100)
for i = 1, 100 do
    positions[i] = { x = 0, y = 0, z = 0 }
end

-- Hash part: slower, requires hashing
local positions = {}
for i = 1, 100 do
    positions["entity_" .. i] = { x = 0, y = 0, z = 0 }
end
Access typeStorageLookup costIteration
t[1], t[2]Array partO(1), direct indexSequential, fast
t["key"]Hash partO(1) amortized, hash + probeUnordered, slower
t[1.5]Hash partO(1) amortizedMixed with string keys

Avoiding GC pressure

The generational GC in Luau divides objects into young and old generations. Young objects (recently created) get collected frequently. If an object survives enough collections, it gets promoted to the old generation which is collected less often.

The practical implication: creating temporary tables in hot loops generates GC pressure even if the tables are short-lived.

-- Allocates a new Vector3 every frame for every part
RunService.Heartbeat:Connect(function(dt)
    for _, part in parts do
        local vel = Vector3.new(part.vx * dt, part.vy * dt, part.vz * dt)
        part.Position = part.Position + vel
    end
end)

-- Use CFrame methods that avoid intermediate allocations
RunService.Heartbeat:Connect(function(dt)
    for _, part in parts do
        part.CFrame = part.CFrame + Vector3.new(
            part.vx * dt, part.vy * dt, part.vz * dt
        )
    end
end)

The best approach for truly hot paths is to avoid object allocations entirely and work with raw numbers:

-- No allocations, pure arithmetic
RunService.Heartbeat:Connect(function(dt)
    for i, part in parts do
        local cf = part.CFrame
        local x, y, z = cf.X + vx[i] * dt, cf.Y + vy[i] * dt, cf.Z + vz[i] * dt
        part.CFrame = CFrame.new(x, y, z) * cf.Rotation
    end
end)

Profiling in practice

Use the MicroProfiler (Ctrl+F6 in Studio) to see where your frame time goes. Most Roblox games spend time in:

  1. Roblox engine internals such as physics and rendering
  2. Network replication and remote event traffic
  3. Excessive Instance manipulation (creating/destroying parts)
  4. Unthrottled loops that do work every frame when they could do it less often

Script optimization matters when the profiler shows your scripts taking more than 2-3ms per frame. If the engine is eating 14ms of your 16ms budget, making your scripts 10x faster saves you 0.18ms. Focus on the bottleneck.

A pastel desktop scene with a character beside an old computer
Game systems have their own rhythm.