QBCore Client-Side FPS Optimization: Real Fixes for 2026
Practical guide to diagnosing and fixing FPS drops on QBCore servers. Covers thread profiling, NUI budgeting, entity culling, and resource-level Lua optimizations with real code examples.
The FPS Problem on QBCore Servers
Most QBCore servers ship with 40-80 resources running simultaneously. Each resource can register client-side threads, NUI pages, event handlers, and entity loops. When FPS drops below 40 on a 100+ player server, the instinct is to blame the hardware — but the real culprit is almost always poorly written client-side Lua.
This guide walks through the actual tools and techniques to identify bottlenecks and fix them. Every code example here is real, tested Lua and JavaScript that you can paste into your server today.
Step 1: Profiling with resmon and Citizen Timings
Before changing any code, you need data. FiveM gives you two built-in profilers.
resmon (Quick Overview)
Open the F8 console in-game and type:
resmon 1This overlays a per-resource CPU breakdown. Each row shows:
- Resource name
- Server ms — time consumed on the server thread
- Client ms — time consumed on the client thread
Any client resource above 1.0ms is worth investigating. Resources above 3.0ms are actively hurting FPS.
Citizen Timings (Deep Profiling)
For a more granular view, use the profiler built into citizen. This captures stack-level timing data:
profiler record 25This records 25 seconds of profiling data. After it finishes, open the output URL in your browser. You will see a flame graph showing exactly which functions inside each resource are consuming time.
Key things to look for:
- Functions that appear as wide blocks — they run for a long time per call
- Functions that appear frequently stacked — they are called too often
- Any
Wait(0)orWait(-1)calls inside dense loops
Step 2: Fix Your Thread Loops
The single biggest source of FPS loss on QBCore servers is client-side CreateThread loops running at 0ms intervals.
The Problem
This pattern is extremely common in older scripts:
CreateThread(function()
while true do
Wait(0)
local playerPed = PlayerPedId()
local coords = GetEntityCoords(playerPed)
-- Check distance to 50 markers every single frame
for _, marker in ipairs(Config.Markers) do
local dist = #(coords - marker.coords)
if dist < 10.0 then
DrawMarker(1, marker.coords.x, marker.coords.y, marker.coords.z,
0, 0, 0, 0, 0, 0, 1.0, 1.0, 1.0, 255, 0, 0, 100, false, false, 2, false)
end
end
end
end)This runs every single frame (~60 times per second) and iterates over every marker. With 50 markers, that is 3,000 distance calculations per second from a single resource.
The Fix: Tiered Polling
Split your logic into a fast render loop and a slow detection loop:
local nearbyMarkers = {}
-- Slow loop: detect nearby markers every 500ms
CreateThread(function()
while true do
Wait(500)
local playerPed = PlayerPedId()
local coords = GetEntityCoords(playerPed)
local found = {}
for i, marker in ipairs(Config.Markers) do
if #(coords - marker.coords) < 30.0 then
found[#found + 1] = marker
end
end
nearbyMarkers = found
end
end)
-- Fast loop: only draw the markers we already know are close
CreateThread(function()
while true do
local count = #nearbyMarkers
if count == 0 then
Wait(500)
else
Wait(0)
local coords = GetEntityCoords(PlayerPedId())
for i = 1, count do
local marker = nearbyMarkers[i]
if #(coords - marker.coords) < 10.0 then
DrawMarker(1, marker.coords.x, marker.coords.y, marker.coords.z,
0, 0, 0, 0, 0, 0, 1.0, 1.0, 1.0, 255, 0, 0, 100, false, false, 2, false)
end
end
end
end
end)Result: When no markers are nearby, the draw loop sleeps for 500ms instead of running 60 times per second. This alone can save 2-5ms per resource.
Step 3: Optimize Native Calls
FiveM natives are C++ functions called from Lua. Each call has overhead for crossing the Lua-C boundary. Caching frequently used results makes a real difference.
Cache PlayerPedId and Coordinates
-- Bad: calling PlayerPedId() 4 times per frame
CreateThread(function()
while true do
Wait(0)
if IsPedInAnyVehicle(PlayerPedId(), false) then
local veh = GetVehiclePedIsIn(PlayerPedId(), false)
local speed = GetEntitySpeed(GetVehiclePedIsIn(PlayerPedId(), false))
local health = GetEntityHealth(PlayerPedId())
end
end
end)
-- Good: cache the ped once per frame
CreateThread(function()
while true do
Wait(0)
local ped = PlayerPedId()
if IsPedInAnyVehicle(ped, false) then
local veh = GetVehiclePedIsIn(ped, false)
local speed = GetEntitySpeed(veh)
local health = GetEntityHealth(ped)
end
end
end)Use Local References for Globals
Lua resolves global variables through a table lookup. Localizing frequently used globals avoids repeated lookups:
-- At the top of your resource file
local PlayerPedId = PlayerPedId
local GetEntityCoords = GetEntityCoords
local Wait = Wait
-- These are now local references, marginally faster in hot loops
CreateThread(function()
while true do
Wait(0)
local ped = PlayerPedId()
local coords = GetEntityCoords(ped)
-- ...
end
end)This optimization is marginal per-call but compounds across thousands of frames.
Step 4: NUI (CEF) Budget Management
Every resource that uses NUI (HTML/CSS/JS rendered via Chromium Embedded Framework) consumes GPU and CPU. Common offenders:
- HUD resources with animated elements that repaint constantly
- Phone scripts with open WebSocket connections
- Inventory UIs left mounted in the DOM even when hidden
Diagnose NUI Usage
Open the F8 console and run:
nui_devtools mpMenuThis opens Chrome DevTools for the NUI layer. Use the Performance tab to record a timeline and look for:
- JavaScript tasks longer than 16ms (they miss the frame budget)
- Layout thrashing — repeated read/write cycles on DOM properties
- Excessive CSS animations running when the UI is not visible
Fix: Destroy NUI Frames When Not Needed
Many scripts keep their NUI mounted at all times. Instead, toggle visibility at the frame level:
-- Instead of just hiding via CSS:
SendNUIMessage({ action = "hide" })
-- Destroy and recreate the frame:
SetNuiFocus(false, false)
SendNUIMessage({ action = "destroy" })
-- When needed again:
SendNUIMessage({ action = "init" })
SetNuiFocus(true, true)On the JavaScript side, clean up intervals and event listeners:
class="hl-comment">// In your NUI app
window.addEventListener(class="hl-string">"message", (event) => {
if (event.data.action === class="hl-string">"destroy") {
class="hl-comment">// Clear all intervals and timeouts
const highestId = setTimeout(() => {}, class="hl-number">0);
for (let i = class="hl-number">0; i < highestId; i++) {
clearTimeout(i);
clearInterval(i);
}
class="hl-comment">// Remove animated elements from DOM
document.getElementById(class="hl-string">"app").innerHTML = class="hl-string">"";
}
});
Step 5: Entity Culling and Population Control
The GTA V engine renders and processes entities (peds, vehicles, objects) within a radius of the player. On a 100+ player server, the entity count explodes.
Reduce Ambient Population
In your server.cfg, these convars control NPC density:
set sv_maxClients 128
set onesync on
# Reduce ambient peds and vehicles
setr game_reducePedBudget true
setr game_enableReducedTraffic true
# Entity lockdown - disable ambient population entirely
set sv_entityLockdown strictsv_entityLockdown strict prevents any client from creating entities that are not explicitly spawned by a server script. This gives you full control over what exists in the world.
Client-Side Entity Cleanup
For resources that spawn temporary props or peds, always clean them up:
-- Track all entities your resource creates
local spawnedEntities = {}
local function SpawnProp(model, coords)
RequestModel(model)
while not HasModelLoaded(model) do
Wait(0)
end
local obj = CreateObject(model, coords.x, coords.y, coords.z, false, true, false)
spawnedEntities[#spawnedEntities + 1] = obj
SetModelAsNoLongerNeeded(model)
return obj
end
-- Cleanup function — call on resource stop
AddEventHandler("onResourceStop", function(resourceName)
if GetCurrentResourceName() ~= resourceName then return end
for _, entity in ipairs(spawnedEntities) do
if DoesEntityExist(entity) then
DeleteEntity(entity)
end
end
end)Without this, restarting a resource during development leaves orphaned entities that accumulate over time.
Step 6: QBCore-Specific Optimizations
Disable Unused QBCore Modules
QBCore ships with several modules enabled by default. If you are not using them, disable them in your server.cfg:
# Only ensure what you actually use
ensure qb-core
ensure qb-multicharacter
ensure qb-spawn
ensure qb-hud
ensure qb-inventory
# Don039;t ensure these if you have replacements:
# ensure qb-target -- if using ox_target
# ensure qb-menu -- if using ox_lib menus
# ensure qb-input -- if using ox_lib input
# ensure qb-phone -- if using a different phoneEach resource you remove saves both server and client CPU cycles.
Optimize qb-target Zones
qb-target (or ox_target) uses raycasting every frame to detect what the player is looking at. If you have hundreds of target zones, consider:
- Use
distanceparameter — Set a maximum interaction distance so the raycast only checks nearby zones - Prefer entity targets over zone targets — Entity targets only activate when the entity exists in the player's scope
- Remove targets when no longer needed — Dynamic targets from scripts like job systems should be removed when the player goes off-duty
-- Good: target with distance limit
exports['qb-target039;]:AddBoxZone("myzone", vector3(100.0, 200.0, 30.0), 2.0, 2.0, {
name = "myzone",
heading = 0,
debugPoly = false,
minZ = 29.0,
maxZ = 32.0,
}, {
options = {
{
type = "client",
event = "myresource:interact",
icon = "fas fa-hand",
label = "Interact",
canInteract = function()
-- Additional check: only show when on duty
return QBCore.Functions.GetPlayerData().job.onduty
end,
},
},
distance = 2.5, -- Only check when player is within 2.5 units
})
Replace Polling with Events
Many QBCore resources poll for state changes. Replace these with event-driven patterns when possible:
-- Bad: check job every second
CreateThread(function()
while true do
Wait(1000)
local job = QBCore.Functions.GetPlayerData().job
if job.name == "police" and job.onduty then
-- show police UI
end
end
end)
-- Good: react to the event QBCore already fires
RegisterNetEvent("QBCore:Client:OnJobUpdate", function(jobInfo)
if jobInfo.name == "police" and jobInfo.onduty then
-- show police UI
else
-- hide police UI
end
end)QBCore fires QBCore:Client:OnJobUpdate whenever the player's job data changes. There is no reason to poll for it.
Step 7: Measure After Every Change
After applying each optimization:
- Run
resmon 1and note the client ms of the changed resource - Use
profiler record 10to capture a before/after comparison - Monitor total frame time — open F8 and check
cl_drawFPS 1for a rolling FPS counter - Test with multiple players — some issues only appear at scale
cl_drawFPS 1Keep a log of what you changed and the measured impact. Not every optimization gives the same result on every server — your resource mix and player count determine what matters most.
Quick Reference: Common FPS Killers
| Problem | Impact | Fix |
|---|---|---|
Wait(0) in detection loops | 3-8ms per resource | Tiered polling (500ms detect, 0ms draw) |
Uncached PlayerPedId() | 0.1-0.5ms cumulative | Local variable per frame |
| NUI left mounted when hidden | 2-10ms GPU | Destroy/recreate frames |
| Orphaned entities after restart | 1-5ms cumulative | onResourceStop cleanup handler |
| Hundreds of qb-target zones | 1-3ms | Distance limits + entity targets |
| Polling for state changes | 0.5-2ms per resource | QBCore event handlers |
| Ambient population on OneSync | 5-15ms | sv_entityLockdown strict |
How This Connects to Script Quality
Every script from Alone Scripts is built with these principles from the ground up. Our Jobs Creator runs at 0.00ms resmon by using event-driven architecture with tiered rendering loops. The Dynamic Fishing system uses zone-based activation so the fishing logic is completely dormant until a player enters a fishing area.
If you are building your own scripts, follow the patterns in this guide. If you want scripts that already follow them — browse our catalog.
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 — €29.99