FiveM Lua Performance: Writing Scripts That Don't Lag (2026)
Master Lua performance for FiveM — learn about the Citizen runtime model, common anti-patterns, optimization techniques, and how to profile your scripts for maximum efficiency.
Why Lua Performance Matters in FiveM
FiveM scripts run inside a custom Lua runtime built on top of the Citizen framework. Unlike standalone Lua applications, FiveM scripts share a cooperative event loop — every resource runs on the same thread. A single poorly-written script can drag down the entire server.
In 2026, with servers scaling to 200+ simultaneous players, writing efficient Lua is no longer optional. This guide teaches you the patterns and anti-patterns that separate a well-optimized server from one plagued by desync and lag.
The FiveM Runtime Model
FiveM uses a cooperative multitasking model:
- Server tick loop runs at ~64 Hz (every ~15.6 ms)
- Client frame loop runs at the player's FPS (typically 30–120 Hz)
- Scripts registered via
CreateThreadare coroutines yielded byCitizen.Wait - Between yields, no other coroutine can run — your code blocks everything
This means a single script that takes 5 ms to execute per tick steals 32% of the server's total tick budget. With 50+ scripts running, every millisecond counts.
Citizen.Wait — The Most Important Function
Citizen.Wait(ms) is how scripts yield control back to the scheduler. The parameter is the minimum time before the coroutine resumes:
| Wait Value | Behavior | Use When |
|---|---|---|
Wait(0) | Resume next frame | Drawing markers, UI, animations |
Wait(100) | ~10 times/second | Near-player checks, interactions |
Wait(500) | Twice per second | Medium-range distance checks |
Wait(1000) | Once per second | Background tasks, stat updates |
Wait(5000+) | Infrequent checks | Cleanup tasks, periodic syncs |
The golden rule: Use the highest Wait value that still provides acceptable responsiveness for the feature.
Common Performance Anti-Patterns
1. Tick Loops with Wait(0) and No Distance Gate
-- ANTI-PATTERN: runs at 60+ FPS regardless of context
CreateThread(function()
while true do
Citizen.Wait(0)
local coords = GetEntityCoords(PlayerPedId())
for _, zone in ipairs(zones) do
if #(coords - zone.pos) < 2.0 then
DrawMarker(...)
end
end
end
end)
Problem: Even when no zone is nearby, you still call GetEntityCoords and iterate all zones every single frame.
Fix: Use a two-thread pattern — one slow thread for distance detection, one fast thread for rendering:
local nearbyZone = nil
-- Slow thread: detect nearby zone
CreateThread(function()
while true do
local coords = GetEntityCoords(PlayerPedId())
nearbyZone = nil
for _, zone in ipairs(zones) do
if #(coords - zone.pos) < 50.0 then
nearbyZone = zone
break
end
end
Citizen.Wait(nearbyZone and 200 or 1000)
end
end)
-- Fast thread: only draws when needed
CreateThread(function()
while true do
if nearbyZone then
Citizen.Wait(0)
DrawMarker(nearbyZone.type, nearbyZone.pos.x, ...)
else
Citizen.Wait(500)
end
end
end)
2. Repeated GetEntityCoords Every Frame
Getting entity coordinates is a native call that crosses the Lua-C boundary. Calling it multiple times per frame wastes cycles:
-- ANTI-PATTERN: three native calls for the same data
local dist1 = #(GetEntityCoords(ped) - pos1)
local dist2 = #(GetEntityCoords(ped) - pos2)
local dist3 = #(GetEntityCoords(ped) - pos3)
Fix: Cache it once per frame:
local coords = GetEntityCoords(ped)
local dist1 = #(coords - pos1)
local dist2 = #(coords - pos2)
local dist3 = #(coords - pos3)
3. String Concatenation in Hot Paths
Lua creates a new string object for every concatenation. In a tight loop, this causes GC pressure:
-- ANTI-PATTERN
for i = 1, 1000 do
result = result .. items[i].name .. ", "
end
Fix: Use table.concat:
local parts = {}
for i = 1, 1000 do
parts[i] = items[i].name
end
local result = table.concat(parts, ", ")
4. Table.insert in Performance-Critical Loops
table.insert(t, val) recalculates the table length on each call. For known-size tables, use indexed assignment:
-- Slower
for i = 1, count do
table.insert(results, processItem(i))
end
-- Faster
for i = 1, count do
results[i] = processItem(i)
end
Optimization Techniques
Distance Checks Before Heavy Logic
Always check if the player is close enough before running expensive code. This single technique eliminates 90% of wasted client-side computation:
local coords = GetEntityCoords(PlayerPedId())
if #(coords - targetPos) > 100.0 then
Citizen.Wait(2000) -- player is far, sleep long
goto continue
end
Caching Natives and Coordinates
Store frequently-accessed values in local variables at the top of your thread loop:
local PlayerPedId = PlayerPedId -- cache the function reference
local GetEntityCoords = GetEntityCoords
CreateThread(function()
while true do
local ped = PlayerPedId()
local coords = GetEntityCoords(ped)
-- use coords throughout this tick
Citizen.Wait(100)
end
end)
Event-Driven vs Polling
Instead of polling for state changes every frame, use events:
-- POLLING (wasteful)
CreateThread(function()
while true do
if IsPlayerInVehicle() then doSomething() end
Citizen.Wait(100)
end
end)
-- EVENT-DRIVEN (efficient)
AddEventHandler('gameEventTriggered', function(event, data)
if event == 'CEventNetworkPlayerEnteredVehicle' then
doSomething()
end
end)
Statebags Instead of Sync Events
FiveM statebags (introduced with OneSync) allow state synchronization without custom events. They are more efficient for data that changes infrequently:
-- Setting state (server)
Player(source).state:set('onDuty', true, true)
-- Reading state (client or server)
local onDuty = LocalPlayer.state.onDuty
Profiling Your Scripts
Beyond resmon, FiveM offers a built-in profiler:
- Open the F8 console
- Type
profiler record 10— records 10 seconds of data - Type
profiler view— opens the profiler viewer - Identify the specific functions consuming the most time
For server-side profiling, check the txAdmin Performance dashboard for per-resource CPU metrics over time.
See our complete resmon guide for detailed instructions on interpreting and acting on performance data.
Checklist: Lua Performance Review
- ⬜ Every
CreateThreadloop has an appropriateCitizen.Waitvalue - ⬜ No
Wait(0)without a proximity or state condition - ⬜ Native calls are cached at the top of loop iterations
- ⬜ String concatenation avoids the
..operator in loops - ⬜ Distance checks gate all expensive logic
- ⬜ Events are used instead of polling where possible
- ⬜ Statebags replace custom sync events for shared state
- ⬜ Resmon shows 0.00 ms idle for all custom scripts
Premium scripts like Jobs Creator by Alone Studios demonstrate these patterns in a production codebase — 0.00 ms idle, instant response when players interact with job markers.
Ready to Transform Your Server?
FiveM Job Creator eliminates every problem discussed in this article. 0.00ms resmon. No-code configuration. ESX & QBCore native.
Get Job Creator on Tebex — €29.99