02. Optimization Patterns
Plain English
Beyond Wait discipline (previous file), there’s a set of patterns that cut CPU without cutting features. Apply them as you write - easier than retrofitting later.
1. Cache Expensive Reads
Don’t call the same native multiple times in one tick.
Bad
CreateThread(function()
while true do
if IsPedInAnyVehicle(PlayerPedId(), false) then -- native call 1
if GetVehicleClass(GetVehiclePedIsIn(PlayerPedId(), false)) == 18 then -- 3 more calls
-- emergency vehicle
end
end
Wait(500)
end
end)
Four native calls per tick. Most of them resolve to the same handles.
Good
CreateThread(function()
while true do
local ped = PlayerPedId() -- one call, cache it
if IsPedInAnyVehicle(ped, false) then
local veh = GetVehiclePedIsIn(ped, false) -- one call, cache it
if GetVehicleClass(veh) == 18 then
-- emergency
end
end
Wait(500)
end
end)
Half the native calls. Same logic.
2. Locals Beat Globals In Hot Loops
-- BAD: math.floor is a global lookup every call (1000 hash lookups)
for i = 1, 1000 do
print(math.floor(i / 2))
end
-- GOOD: cache the function once. local stack access in the loop.
local floor = math.floor
for i = 1, 1000 do
print(floor(i / 2))
end
Local variables are direct stack reads. Globals are hash table lookups. In hot loops, the difference adds up.
3. State Bags Over Net Event Spam
If you’re continuously syncing some state to all clients, state bags beat firing net events 10× per second.
-- ↓ on the server, set state on the entity. "true" = replicate to all clients.
Entity(vehicle).state:set('fuel', 75, true)
Player(src).state:set('stress', 42, true)
-- ↓ clients read it (synced automatically)
local fuel = Entity(vehicle).state.fuel
State bags auto-replicate on change and clean up on disconnect. Use for fuel, stress, ownership flags, anything continuous.
4. Early Return On “Nothing To Do”
lib.onCache('ped', function(newPed)
if not newPed then return end -- ped is nil, skip
-- only react when there's actually a new ped
end)
Most update handlers fire often. Early-exit on the boring cases first.
5. Batch DB Writes
Writing on every coin earned = tons of tiny queries.
Bad
local function onCoinEarn(src)
MySQL.update('UPDATE players SET coins = coins + 1 WHERE cid = ?', { getCid(src) })
end
If 50 players earn 10 coins/min, that’s 500 queries/min just for “+1 coin”.
Good - In-Memory Queue + Periodic Flush
local dirty = {} -- cid → pending coin delta
local function onCoinEarn(src)
local cid = getCid(src)
dirty[cid] = (dirty[cid] or 0) + 1 -- bump the counter
end
CreateThread(function()
while true do
Wait(30000) -- flush every 30s
for cid, amount in pairs(dirty) do
MySQL.update('UPDATE players SET coins = coins + ? WHERE cid = ?', { amount, cid })
dirty[cid] = nil
end
end
end)
-- ↓ flush a player's pending counter on disconnect (don't lose their coins)
AddEventHandler('playerDropped', function(src)
local cid = getCid(src)
if dirty[cid] then
MySQL.update.await('UPDATE players SET coins = coins + ? WHERE cid = ?',
{ dirty[cid], cid })
dirty[cid] = nil
end
end)
Tradeoff: up to 30s of coin loss on a server crash. Acceptable for non-money - for actual cash, use atomic per-event writes.
6. Don’t Send Full Tables Every Tick
If clients want a list of players, don’t broadcast the full list 10× a second. Send deltas:
-- on player load: tell everyone "this player joined"
AddEventHandler('qbx_core:server:playerLoaded', function(playerData)
TriggerClientEvent('players:add', -1, playerData.source, playerData.charinfo.firstname)
end)
-- on disconnect: tell everyone "they left"
AddEventHandler('playerDropped', function()
TriggerClientEvent('players:remove', -1, source)
end)
Each client maintains its own copy. Tiny network cost. No periodic sync needed.
7. Avoid GetGamePool In Hot Code
-- EXPENSIVE: traverses every vehicle the engine tracks
for _, veh in ipairs(GetGamePool('CVehicle')) do
-- check it
end
For nearby entities, prefer:
local nearby = lib.getNearbyVehicles(coords, 50.0, false) -- batched, radius-limited
Save GetGamePool for one-shot operations (cleanup on resource stop, debug commands), not loops.
8. Use CreateThread Sparingly
Each thread = its own coroutine + scheduler overhead. Don’t spawn one per NPC.
Bad - 100 threads for 100 NPCs
for _, npc in ipairs(npcs) do
CreateThread(function()
while true do
-- manage this one NPC
Wait(1000)
end
end)
end
Good - one thread, iterate
CreateThread(function()
while true do
for _, npc in ipairs(npcs) do
-- manage each NPC in turn
end
Wait(1000)
end
end)
One thread. Same work. Less overhead.
9. Targeted Event Routing
-- BAD: broadcasts to all 128 connected clients
TriggerClientEvent('shop:updated', -1, data)
-- GOOD: only the buyer cares
TriggerClientEvent('shop:updated', buyerSrc, data)
-- GOOD: only nearby players (e.g., you opened a robbery, only nearby cops should know)
local nearby = lib.getNearbyPlayers(coords, 50.0)
for _, src in ipairs(nearby) do
TriggerClientEvent('shop:updated', src, data)
end
-1 broadcast = 128× the network traffic. Only use it when literally every player needs the event.
10. Stream Models On Demand, Unload After
local model = `adder`
RequestModel(model)
while not HasModelLoaded(model) do Wait(0) end
local veh = CreateVehicle(model, x, y, z, h, true, false)
SetModelAsNoLongerNeeded(model) -- ALWAYS release after use
Forget SetModelAsNoLongerNeeded → memory leak, future model requests may fail because the streaming slots are full.
Same for animation dictionaries:
RequestAnimDict(dict)
-- ... play the anim ...
RemoveAnimDict(dict) -- release when done
11. Don’t Render NUI When Hidden
Even with visibility: hidden, your React useEffects still run. Gate expensive work:
useEffect(() => {
if (!visible) return; // bail when hidden
const timer = setInterval(() => {
setTime(Date.now());
}, 1000);
return () => clearInterval(timer);
}, [visible]);
Don’t run animations, polling, or fetches behind a hidden UI.
12. Throttle SendNUIMessage
Sending every frame to NUI = expensive serialization + CEF overhead.
For HUDs (HP, hunger, etc.):
local lastSent = {}
CreateThread(function()
while true do
local hp = GetEntityHealth(PlayerPedId())
if hp ~= lastSent.hp then -- only send when changed
SendNUIMessage({ hp = hp })
lastSent.hp = hp
end
Wait(500)
end
end)
Only send on actual changes. Aggressive throttling on HUDs is a huge win.
13. Profile Before Optimizing
Don’t guess. Tools:
resmonin F8 console - quick per-resource CPUprofiler record 500+profiler save x.json- open in Chrome DevTools for flame chart- MCP
resmon_snapshot+benchmark_compare- before/after numbers
Optimizing cold code = wasted effort. Find the 20% of code eating 80% of CPU first.
14. Lua GC Awareness
Avoid creating garbage in hot loops:
-- BAD: allocates a new vector3 every frame
while true do
local c = vec3(1, 2, 3)
Wait(0)
end
Lua garbage collection pauses cause tiny hitches. Reuse values, hoist allocations out of hot loops.
15. Compile-Time Constants
-- ↓ ONE config table at the top, easy to find and tune
local CONFIG = {
SHOP_COORDS = vec3(25.0, -1347.0, 29.5),
SHOP_RADIUS = 3.0,
SHOP_ITEMS = { bread = 10, water = 5 },
}
Avoid magic numbers sprinkled across the file. Easier to tune, debug, and review.
16. Preload Critical Assets On Resource Start
If you know you’ll need a model or anim, load it once at start:
CreateThread(function()
Wait(2000) -- let the game initialize
for _, model in ipairs({ `adder`, `zentorno` }) do
RequestModel(model)
while not HasModelLoaded(model) do Wait(0) end
SetModelAsNoLongerNeeded(model) -- still release the slot, the game caches it
end
end)
Models become cached. First in-game use = instant.
17. Stop Processing When Far Away
The global anti-pattern: a thread doing work for every player even when no player is involved.
local players = lib.getNearbyPlayers(coords, 50.0)
for _, src in ipairs(players) do
-- only these players are affected by the event happening at "coords"
end
Gate by distance, by visibility, by “am I involved”. Idle work is a tax on the whole server.
Pre-Ship Performance Checklist
- No
while true do ... Wait(0) endwithout distance gating orlib.points -
resmon< 0.05 ms idle for client resources - DB writes batched or event-driven (not per frame)
- No
GetGamePoolin hot loops - Locals cached in hot loops
- State bags for continuous sync, not net event spam
- Targeted
TriggerClientEvent, not-1broadcast (unless truly global) -
SetModelAsNoLongerNeededafter everyCreateVehicle/CreatePed - NUI receives messages on change, not per frame
-
onResourceStopcleanup (NUI focus, threads, entities, blips)
TL;DR
- Cache repeated native calls
- Batch DB ops, send deltas not snapshots
- Event-driven > polling
- Targeted broadcasts, not
-1 - Unload streamed assets
- Profile with
resmon+profiler - Gate work by distance / visibility / “am I involved”
Sources
- FiveM Lua runtime (profiler entry points)
- State Bags
- FiveM Cookbook (performance) - community patterns
- ox_lib points (source)
- ox_lib zones
- Lua performance tips - official Lua perf guide
Next folder: 10-first-projects/ - start with 01-hello-resource.md