Back to Blog
Performance 14 min read April 17, 2026

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.

qbcore performance fivem fps client optimization fivem lag fix qbcore optimization fivem performance

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:

bash
resmon 1

This 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:

bash
profiler record 25

This 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) or Wait(-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:

lua
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:

lua
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

lua
-- 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:

lua
-- 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:

bash
nui_devtools mpMenu

This 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:

lua
-- 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:

js
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:

cfg
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 strict

sv_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:

lua
-- 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:

cfg
# Only ensure what you actually use
ensure qb-core
ensure qb-multicharacter
ensure qb-spawn
ensure qb-hud
ensure qb-inventory

# Don&#039;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 phone

Each 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:

  1. Use distance parameter — Set a maximum interaction distance so the raycast only checks nearby zones
  2. Prefer entity targets over zone targets — Entity targets only activate when the entity exists in the player's scope
  3. Remove targets when no longer needed — Dynamic targets from scripts like job systems should be removed when the player goes off-duty
lua
-- Good: target with distance limit

exports[&#039;qb-target&#039;]: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:

lua
-- 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:

  1. Run resmon 1 and note the client ms of the changed resource
  2. Use profiler record 10 to capture a before/after comparison
  3. Monitor total frame time — open F8 and check cl_drawFPS 1 for a rolling FPS counter
  4. Test with multiple players — some issues only appear at scale
bash
cl_drawFPS 1

Keep 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

ProblemImpactFix
Wait(0) in detection loops3-8ms per resourceTiered polling (500ms detect, 0ms draw)
Uncached PlayerPedId()0.1-0.5ms cumulativeLocal variable per frame
NUI left mounted when hidden2-10ms GPUDestroy/recreate frames
Orphaned entities after restart1-5ms cumulativeonResourceStop cleanup handler
Hundreds of qb-target zones1-3msDistance limits + entity targets
Polling for state changes0.5-2ms per resourceQBCore event handlers
Ambient population on OneSync5-15mssv_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