A script can work perfectly and still get you banned from a server's resource list. If it eats 8 ms every frame, the owner drops it. If it trusts the client, a cheater drains the economy. This module turns "it runs" into "it ships": fast enough to live on a real server, and locked down so players can't lie to it.
The number that decides everything: resmon
Open your server console (F8 in-game on a server you own) and type resmon. You get a live table of every resource and its CPU time in milliseconds. That column is the whole game. The unwritten rule across the FiveM scene is simple: idle resources should sit near 0.00–0.01 ms, and even while actively doing something a single script should stay under ~4 ms. Cross 4 ms and you cause frame hitches; a few greedy scripts stacking up is why servers feel laggy.
resmon tells you which resource is slow. To find which line is slow, FiveM ships a profiler: run profiler record 500 to capture 500 frames, then profiler view to open a flame-graph in your browser. The wide bars are your problem. Don't guess at performance — measure, fix, measure again. AI assistants love to "optimize" code that was never slow; resmon is how you check whether the change actually moved the number.
Kill per-frame work
The number one cause of a hot resource is a thread that runs every single frame for no reason. In Lua for FiveM, a loop with Wait(0) runs ~60+ times a second. That's fine for one frame of work — it's a disaster when it's drawing a marker or checking distance forever.
Two classic offenders:
DrawMarkerevery frame, everywhere. Markers must be redrawn each frame to stay visible, so the draw call itself belongs in aWait(0)loop — but only when the player is actually close enough to see it.- Distance checks with no backoff. Calculating distance to ten shops 60 times a second is wasted math when the player is across the map.
The fix is adaptive sleep: when nothing's nearby, sleep a long time (e.g. 1000 ms). When the player is close, tighten the loop to Wait(0) so the marker is smooth. Same logic, a fraction of the cost.
-- client.lua — adaptive marker loop. Idle cost stays near 0.00 ms on resmon.
local shops = {
{ coords = vec3(25.7, -1345.3, 29.5), label = "24/7 Store" },
}
local DRAW_RADIUS = 15.0 -- start drawing the marker within this range
local INTERACT_RADIUS = 2.0 -- close enough to actually open the shop
CreateThread(function()
while true do
-- Default to a long sleep. We only shorten it if something is near.
local sleep = 1000
local ped = PlayerPedId()
local pos = GetEntityCoords(ped)
for _, shop in ipairs(shops) do
local dist = #(pos - shop.coords) -- vector length = distance
if dist < DRAW_RADIUS then
-- Player can see it: we MUST run every frame to draw smoothly.
sleep = 0
DrawMarker(2, shop.coords.x, shop.coords.y, shop.coords.z,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.3, 0.3, 0.3, 30, 144, 255, 120,
false, true, 2, false, nil, nil, false)
if dist < INTERACT_RADIUS and IsControlJustReleased(0, 38) then
-- E pressed near the shop. lib.callback asks the SERVER,
-- which is the only place we trust the answer.
local ok = lib.callback.await('playdeck:openShop', false, shop.label)
if ok then exports.ox_inventory:openInventory('shop', shop.label) end
end
end
end
Wait(sleep) -- long when far, 0 when close
end
end)
Better still: for static interaction points, skip the loop entirely and register the zone with ox_target or ox_lib points. Those systems share one optimized loop across every resource, so your script's idle cost drops to basically nothing. Link to the official ox_target and ox_lib docs and use their addBoxZone / lib.points APIs instead of rolling your own — that's the modern, ox-first way.
Debounce your database writes
Saving to MySQL on every tiny change will tank your server under load. If a player's position or inventory saves 60 times a second, you've built a DDoS against your own database. Debounce: collect changes and flush them on a timer or on meaningful events (logout, periodic autosave) instead of every frame.
-- server.lua — flush dirty players every 5 minutes, not every change.
local dirty = {}
function MarkDirty(src) dirty[src] = true end -- call this when data changes
CreateThread(function()
while true do
Wait(5 * 60 * 1000) -- 300,000 ms = 5 minute autosave window
for src in pairs(dirty) do
SavePlayer(src) -- one batched write per player, not per change
dirty[src] = nil
end
end
end)
Server-side validation is the only real security
Here's the rule that separates hobby scripts from shippable ones: the client always lies. Anything a player's game sends you — coordinates, item counts, prices, "I'm allowed to do this" — can be forged with a menu cheat. The client is for display and input. The server is for deciding.
That's why the example above calls lib.callback.await('playdeck:openShop', ...). The client asks; the server decides and re-checks everything:
-- server.lua — never trust the client's claim. Verify it yourself.
lib.callback.register('playdeck:openShop', function(source, shopLabel)
local ped = GetPlayerPed(source)
local pos = GetEntityCoords(ped)
-- Re-check distance SERVER-SIDE so a cheater can't open a shop from across the map.
if #(pos - vec3(25.7, -1345.3, 29.5)) > 3.0 then return false end
return true
end)
This is where AI help goes wrong most often. Code assistants happily trust a client-sent price or item id, mix QBCore and ESX exports in one file, or invent natives that don't exist. Pin one framework (Qbox/QBCore or ESX), verify every native and export against the official FiveM reference and your PlayDeck sandbox, and put every decision that affects money, items, or permissions on the server.
Practice
In the PlayDeck browser sandbox, paste a loop that runs print("tick") inside Wait(0) and watch the simulated tick counter explode. Now change it to the adaptive pattern: start with local sleep = 1000, and only set sleep = 0 when a mock distance variable (set it to 1.5) is under 5.0. Re-run and watch the tick rate collapse. Then flip the distance to 50.0 and confirm it idles. You just felt the difference resmon would show — no FiveM install required.
Recap
- Measure with
resmonand theprofiler— target near 0 ms idle, under ~4 ms active. Never optimize on a hunch. - Kill per-frame waste with adaptive
Wait()sleep, or hand interaction zones toox_target/ox_libpoints. - Debounce DB writes — batch and autosave on a timer, never save per frame.
- Validate on the server. The client only asks; the server decides and re-checks distance, price, and permissions. This is also where AI-written code fails — pin one framework and verify against the real reference.