Performance
Optimize an ESX Legacy server with resmon and the profiler, smarter thread waits, state bags instead of events, database indexes, paycheck batching and OneSync.
Most ESX performance problems come from scripts, not from the framework core: client loops that run every frame, server events that fire too often and database queries without indexes. This guide shows how to measure first, then fix the usual causes in order of impact.
Measure before you optimize
Client: resmon
Open the F8 console in game and run:
resmon 1You see CPU time (ms) and memory per resource. Stand still somewhere quiet, then near the places your scripts care about (shops, job markers). A resource that stays high everywhere has an always-running loop.
Server: profiler
In the server console (or txAdmin live console):
profiler record 500
profiler viewprofiler record captures the given number of frames, profiler view opens the result in the browser-based viewer. Look for resources with long tick times or event handlers that run constantly. See the cfx.re profiler docs.
Database: slow queries
oxmysql prints warnings for slow queries in the server console. Note the query and the resource. Those are your index candidates (see below).
Fix thread waits
The single biggest client cost is while true do Wait(0) ... end that runs whether or not the player is near anything.
-- Bad: runs every frame for every player, everywhere
CreateThread(function()
while true do
Wait(0)
local coords = GetEntityCoords(PlayerPedId())
if #(coords - vector3(25.7, -1347.3, 29.5)) < 2.0 then
ESX.ShowHelpNotification('Press ~INPUT_CONTEXT~ to open the shop')
end
end
end)-- Better: sleep long when far away, only run per frame when close
local SHOP <const> = vector3(25.7, -1347.3, 29.5)
CreateThread(function()
while true do
local sleep = 1000
local distance = #(GetEntityCoords(PlayerPedId()) - SHOP)
if distance < 2.0 then
sleep = 0
ESX.ShowHelpNotification('Press ~INPUT_CONTEXT~ to open the shop')
elseif distance < 20.0 then
sleep = 250
end
Wait(sleep)
end
end)Rules of thumb:
- Only use
Wait(0)while you draw something or read a key every frame. - Start job-specific loops when the player gets the job and stop them when the job changes, instead of checking
ESX.PlayerData.job.nameevery frame. Listen toesx:setJobon the client. - Cache values that rarely change (
PlayerId(),GetPlayerServerId(PlayerId())). Update a cached ped onesx:playerPedChanged, whiches_extendedtriggers when the ped changes. - Use the built-in point helpers (
ESX.Point) orox_libpoints rather than writing your own distance loops for every marker.
State bags instead of polling events
A common anti-pattern is a client that asks the server every few seconds "what is my job?" or "how many police are online?". ESX already replicates this data with state bags, which are synced by the engine only when they change.
| State | Set by es_extended | Replicated to clients |
|---|---|---|
Player(src).state.job | On load, xPlayer.setJob() and job refresh | Yes |
Player(src).state.group | On load and xPlayer.setGroup() | Yes |
Player(src).state.name | On load and name change | Yes |
Player(src).state.identifier, .license | On load | No (server only) |
GlobalState.playerCount | On player load and drop | Yes |
GlobalState['<job>:count'] | When job counts change, for example GlobalState['police:count'] | Yes |
Read them instead of building your own sync:
-- Number of police online without any server callback
local function getPoliceCount()
return GlobalState['police:count'] or 0
end
-- React to your own job changing
AddStateBagChangeHandler('job', ('player:%s'):format(GetPlayerServerId(PlayerId())), function(_, _, job)
if job then
print(('Job is now %s grade %s'):format(job.name, job.grade))
end
end)On the server, ESX.GetNumPlayers('job', 'police') returns the same count from memory without looping over players.
Do not read state bags every frame
Reading GlobalState deserializes the value. ESX itself caches the population density state in a change handler for this reason. Cache the value in a local and update it in AddStateBagChangeHandler.
Use events and callbacks sparingly
- Do not trigger server events from a client loop. Trigger them on an action (key press, menu choice).
- Prefer one callback that returns everything a menu needs over five callbacks.
- ESX callbacks go through
esx_lib, which rate limits callbacks per player and rejects payloads larger thanxLib:callbackMaxPayloadBytes(default 65536 bytes). If your callback sends huge tables, send less data. See Callbacks. - On the server,
ESX.GetExtendedPlayers('job', 'police')filters in memory. Use it instead of looping overGetPlayers()and callingESX.GetPlayerFromIdon every entry.
Database performance
Query patterns
- Never run a query per player per tick. Load data once, keep it in memory, write it back on change or on a timer.
- Select only the columns you need instead of
SELECT *onusers, which contains severalLONGTEXTJSON columns. - Use
MySQL.preparefor statements you run very often, and batch inserts. See oxmysql.
Indexes
legacy.sql already defines primary keys and the important secondary keys, for example owned_vehicles.owner, billing.identifier, user_licenses (owner, type) and the addon_account_data and datastore_data owner keys. Several addons add their own indexes at startup, for example esx_garage adds composite indexes on owned_vehicles and esx_banking adds idx_banking_identifier_time.
The old security_indexes.sql in ESX-Legacy-Addons is superseded and now only prints a message. Its replacement is sql/security_hardening_2026_10_02.sql. It is optional because esx_billing, esx_license, esx_addonaccount and esx_banking apply these schema changes automatically on startup. If you run it by hand:
Back up the database
DDL statements commit implicitly. The script is not a rollback transaction.
Check for duplicates
The script stops with an error if user_licenses or addon_account_data contain duplicate rows, and it never merges financial rows for you. Reconcile them first.
Run it in a maintenance window
It requires MySQL 8 or MariaDB 10.6+ and InnoDB for the economy tables. It is safe to re-run.
Do not blindly add indexes from forum posts. The file itself warns that InnoDB secondary indexes already contain the primary key, so indexes like (owner, plate) on owned_vehicles are redundant. Use EXPLAIN on the slow query first, then add the index that matches its WHERE clause.
Framework settings that matter
| Key | Default | Effect |
|---|---|---|
Config.PaycheckInterval | 7 * 60000 | How often paychecks run (ms) |
Config.PaycheckBatchSize | 100 | Players paid before the thread yields |
Config.PaycheckBatchDelay | 250 | Wait between batches (ms) |
Config.PaycheckJitter | 30 * 1000 | Random delay before each run, avoids synchronized bursts |
Config.EnableDebug | false | Extra debug output. Keep it off in production |
Config.AdminLogging | false | Discord logging for admin commands. Each log is an HTTP request |
Config.LogPaycheck | false | Logs every paycheck to Discord. Expensive on big servers |
All are in es_extended/shared/config/main.lua. See Main config.
Player saving is already efficient: es_extended saves all players every 10 minutes in one background thread, saves a player when they drop and saves everyone before a txAdmin scheduled restart and on shutdown. You do not need extra "save all" loops.
OneSync and the server artifact
- ESX Legacy requires OneSync Infinity. With
onesyncset toofforlegacy, players are rejected on join. txAdmin enables OneSync by default; in a manual setup addset onesync on. es_extendedrequires server artifact 10188 or newer and prints an error every minute if the artifact is older. Updating the artifact is also one of the cheapest performance fixes.- With OneSync, prefer server-side entity creation (
ESX.OneSync.SpawnVehicle,ESX.OneSync.SpawnObject) and server-side lookups (ESX.OneSync.GetPlayersInArea) over asking clients.
Common issues
- A resource shows 0.5 ms or more in resmon everywhere: it has an always-running
Wait(0)loop. Apply the sleep pattern above. - Server hitches every few minutes: check what runs on a timer. Paychecks, autosave and Discord logging are the usual suspects. Lower
Config.PaycheckBatchSizeor turn off paycheck logging. - Hitches when many players join: look for scripts that run heavy queries in
esx:playerLoaded. - "ESX Requires a minimum Artifact version of 10188": update your FXServer artifact.
Related
Frequently asked questions
Independent documentation
Last updated on
Database Schema
Every ESX Legacy database table explained with columns, types, keys and the resources that use them, from users and jobs to billing, banking and owned_vehicles.
ESX Legacy FAQ
Answers to the most common ESX Legacy questions - installation, current version, OneSync, oxmysql, admin rights, giving money, adding items and getting xPlayer.