Optimising a FiveM server for performance

How to find the resource costing you frame time, read resmon and the profiler, and tell a code problem from a hardware one.

Game servers13 min read

On this page
  1. First, decide which side is slow
  2. Reading resmon
  3. What the numbers mean
  4. The memory column
  5. resmon 2
  6. Measuring the server
  7. txAdmin's performance chart
  8. The built-in profiler
  9. Timing one suspect by hand
  10. The offenders you will actually find
  11. A loop with no wait
  12. Every-frame work that does not need to be every frame
  13. Database queries in a tick
  14. Events and state bags sent to everyone
  15. Too many entities
  16. Map and MLO resources
  17. Console output and webhooks
  18. OneSync, player count, and population
  19. What actually depends on the machine
  20. A workflow that finds it
  21. Related

Almost every FiveM performance problem is resource code, not the machine. Before you change anything, work out which side is slow, the server or the client, because the tools, the numbers, and the fixes are different for each.

This page is the full method: how to measure, how to read what you measured, and what to do about the things you will find. If you arrived mid-incident with a warning in your console, go straight to server thread hitch warnings or network thread hitch warnings and come back here afterwards.

First, decide which side is slow

The single most expensive mistake in FiveM tuning is fixing the wrong side. These symptoms sort themselves.

What you seeWhere the problem is
server thread hitch warning in the server consoleThe server's main thread. Your server-side script code, or a query it is waiting on.
network thread hitch warning in the server consolePacket and sync load, not script code. Different page, different causes.
Everyone rubber-bands at once, commands and menus answer late, chat lags, but frame rate is fineThe server
Frame rate drops, stutter when you drive into a busy area, textures go flat or turn whiteThe client
One player is slow and nobody else isThat player's machine or connection
Everything is fine until roughly the same player count every nightLoad that scales with players: sync, per-player loops, or queries per player

Frame rate and server performance are separate measurements. A server can be hitching for half a second at a time while every client sits at a steady frame rate, and a client can be at 20 fps on a server whose main thread is idle.

Reading resmon

Press F8 in game to open the client console and type resmon. You get a live table of every running resource with the CPU time it spent in the last frame and the memory it holds.

What the numbers mean

The CPU column is time per frame, in milliseconds. Judge it against your frame budget rather than against a figure someone posted on a forum:

  • At 60 fps a frame is 16.67 ms long.
  • At 100 fps it is 10 ms.
  • At 144 fps it is 6.94 ms.

Everything the game does in a frame, including all of your resources plus the game engine itself rendering the world, has to fit inside that budget. The engine takes the large majority of it. What your resources have left is a small fraction, which is why a single resource sitting at 1 ms is already taking a noticeable slice, and why several resources at 0.5 ms each add up to a frame rate you can feel.

One frame, and one resource at 1 msEverything in a frame, the engine included, has to fit in its length.At 60 fps16.67 msAt 100 fps10 msAt 144 fps6.94 ms051015Milliseconds per frameOne resource at 1 msThe rest of the frameOne frame, and one resource at 1 msEverything in a frame, the engine included, has tofit in its length.At 60 fps16.67 msAt 100 fps10 msAt 144 fps6.94 ms051015Milliseconds per frameOne resource at 1 msThe rest of the frame
The budget a resmon number is judged against, to scale. The same 1 ms resource takes a bigger share of the frame the higher the frame rate, which is why a figure that looks small is already a noticeable slice. Derived from the frame lengths on this page.

So the useful reading is relative, and there are three questions worth asking of the table:

  1. Which resources are at the top? Sort by the CPU column and look at the top three. Those are the only ones worth your time. Optimising the fourth is effort you will not be able to measure.
  2. Does the cost change when you are not using the resource? A menu that costs while it is open and nothing while it is closed is behaving correctly. A resource that costs the same whether you touch it or not is doing work nobody asked for, every frame, forever.
  3. Does the cost climb as you move? Open resmon and drive from an empty part of the map into a dense one. A resource whose cost climbs with entity density is running per-entity or distance work every frame.

Watch it for a few seconds rather than reading one frame. The numbers move constantly and a single spike proves nothing.

The memory column

Memory in resmon is the resource's Lua or JS heap, not its streamed assets. What matters here is the shape over time. A number that sits still is fine. A number that only ever climbs, across a session, is a leak: a table being appended to and never cleared, or handlers being registered repeatedly.

Streamed assets, meaning everything in a resource's stream folder, do not show up as CPU cost. They cost download time on join and video memory once loaded, and they are the usual cause of flat or missing textures.

resmon 2

resmon 2 expands the view. Instead of one line per resource you get the individual threads and event handlers inside each one, with their own timings. It turns "this resource costs 1.2 ms" into "this one tick inside this resource costs 1.2 ms", which is the difference between guessing and knowing which function to open.

Use resmon 1 to find the resource, then resmon 2 to find the code inside it.

Measuring the server

There is no resmon for the server. Two tools cover it.

txAdmin's performance chart

txAdmin ships with the server artifacts and its dashboard carries a performance chart with a selector for the server's threads. It plots how long ticks took over a window of time, so it answers when the server was slow and which thread was slow. It does not tell you which resource, but it is the fastest way to confirm that a problem is real, that it is recurring, and whether it lines up with your player count.

Two readings are worth taking before you go any further:

  • Does the slow period line up with peak players? If it does, the cost scales with players, which points at per-player loops and per-player queries.
  • Which thread? If the main thread is clean and the network thread is not, stop reading here and go to network thread hitch warnings.

The built-in profiler

The profiler is the tool that names the resource. It runs in the server console:

profiler record 300
profiler save profile.json

The first command records a number of frames, the second writes the recording to a file in your server data directory. Load that JSON into a Chrome trace viewer at chrome://tracing to read it, or load it on a game client with profiler load and profiler view.

Run profiler on its own first. It prints the subcommands your artifact build supports, and that list is the authority for your version rather than anything written here.

Two rules for getting a useful recording:

  • Record while the problem is happening. A profile of an idle server at 4am tells you nothing. Start the recording when you can see hitches in the console or a spike on the txAdmin chart.
  • Record a short window. Long recordings produce files that are painful to open and no more informative.

Reading the result: each tick appears as a block on a timeline and the width of a block is the time it took. You are looking for the widest block that repeats. The resource it belongs to is named on it.

One tick: its width is the time it tookinventoryinventoryinventoryThe widest block that repeatsis your cause, and it carries thename of the resource that owns it
What to look for in a recording. Most ticks are narrow; the cause is the block that is far wider than the rest and keeps coming back, and its name tells you which resource to open. Illustration: the resource name is a placeholder.

Timing one suspect by hand

When you already suspect a specific function, time it directly. This works on both the server and the client:

Lua
local started = GetGameTimer()
DoTheExpensiveThing()
print(('DoTheExpensiveThing took %d ms'):format(GetGameTimer() - started))

If that print is anywhere near the size of the hitches you are seeing, you have your answer and you can stop looking.

The offenders you will actually find

A loop with no wait

The first thing to rule out, and still the most common cause of a stalled server.

Lua
Citizen.CreateThread(function()
  while true do
    -- nothing yields here
    DoWork()
  end
end)

A FiveM thread only gives time back to the runtime when it yields, and Citizen.Wait is what yields. A loop with no wait inside it never gives time back. On the client that costs frame rate. On the server it stalls the main thread, and while it is stalled nothing else on the server runs at all: no events, no commands, no other resource.

Find the candidates:

Shell
grep -rn "while true do" resources/ | head -50

Then open each hit and check that a Wait is inside the loop body, not after it and not inside a branch that only sometimes executes. A Wait that sits behind an if is a loop with no wait for every player who does not meet the condition.

Every-frame work that does not need to be every frame

Citizen.Wait(0) means "run me again next frame". Very little needs that. Drawing natives do, because what they draw exists for exactly one frame. Distance checks, state checks, database reads, and anything to do with inventory or money do not.

The standard fix is a loop with a variable sleep: run slowly until you are near the thing, then run every frame only while you are near it.

Lua
local target = vector3(-1037.0, -2737.0, 20.1)
 
CreateThread(function()
  while true do
    local sleep = 1000
    local coords = GetEntityCoords(PlayerPedId())
    if #(coords - target) < 20.0 then
      sleep = 0
      DrawMarker(--[[ marker args ]])
    end
    Wait(sleep)
  end
end)

A map of 40 markers written the naive way is 40 threads running every frame. The same map written this way is 40 threads running once a second, and one or two of them running every frame while you stand next to something.

Database queries in a tick

A query inside a loop is the classic way to turn a working server into a hitching one at 30 players. Read once, keep the result in a table, and write on change rather than on a timer. Full detail, including how to prove it is your database rather than your code, is on server thread hitch warnings, because that is where this one usually announces itself.

Events and state bags sent to everyone

TriggerClientEvent(name, -1, payload) sends to every connected player. Called once, that is nothing. Called on a timer, or once per player in a loop over all players, the cost multiplies by your player count and lands on the network side.

Three habits that keep it small:

  • Send to the players who need it, not to -1.
  • For anything large, use TriggerLatentClientEvent, which takes a byte rate and spreads the payload out over time instead of pushing it all at once.
  • When you set a state bag, pass the replication argument deliberately. Entity(vehicle).state:set('key', value, true) replicates to clients, and doing that inside a tick replicates it every frame. If clients do not read the value, pass false.

Too many entities

Every networked entity is work: it is tracked, it is synced, and it is considered for every player near it. Servers accumulate them. Vehicles nobody deleted, props from a job that crashed, peds from a script that spawned them and forgot.

With OneSync on you can count them from the server console or a command:

Lua
print(('vehicles %d, peds %d, objects %d'):format(
  #GetAllVehicles(), #GetAllPeds(), #GetAllObjects()))

Take that count when the server is fresh and again after a busy evening. A number that only grows is a cleanup problem, and it will show up as a network thread problem long before it shows up as a main thread one.

sv_entityLockdown in your server.cfg decides whether clients may create entities at all. Setting it to relaxed or strict stops a modded client spawning a thousand objects into your server, which is both a performance control and a security one. See server.cfg for where it goes.

Map and MLO resources

Streamed content does not cost server tick time. It costs:

  • Client video memory, which is what causes flat or white textures when it runs out.
  • Download time on join, once per player per change.
  • Disk reads on your server while players are downloading it.

So a heavy car pack or an unoptimised interior is a client problem and a join-time problem. It is not why your server thread hitches, and moving to a bigger plan will not change it. Find the large ones:

Shell
du -sh resources/*/stream 2>/dev/null | sort -h | tail -20

The usual offenders are uncompressed textures at resolutions nobody sees, and several copies of the same vehicle pack under different names.

Console output and webhooks

print is not free, and printing on every tick is slow enough to measure, particularly on Windows where console writes are expensive. The same goes for a Discord webhook fired per event: each one is an HTTP request, and a busy server firing one per player action will queue them faster than it drains them.

Keep logging proportionate: log the events you would actually read.

OneSync, player count, and population

These live in your server.cfg and they set the ceiling on everything above.

  • onesync takes on, legacy, or off. With it off you are on the base GTA network model and limited to 32 clients. on is the current mode and is what raises the ceiling, because entities are culled by distance so each client only receives what is near it. legacy is the older behaviour and does not scale the same way.
  • sv_maxclients is the slot count you ask for. What you actually get depends on the OneSync mode and on your licence tier, and the server clamps your number to it, so read the startup console for the limit it settled on. Setting it far above what your scripts can carry does not add capacity, it only lets more people in before the hitching starts. See server.cfg.
  • onesync_population controls whether ambient traffic and pedestrians are networked. Turning it off takes a whole class of entities out of sync work. On a roleplay server where ambient traffic is decoration rather than gameplay, this is one of the few settings that changes entity counts by a large factor. Measure your entity counts before and after with the snippet above rather than trusting that it helped.

Typing a convar name on its own in the server console prints its current value, which is the reliable way to see what is actually in force rather than what you think your config sets.

What actually depends on the machine

This is the part people skip, so it is worth being blunt about.

The FiveM server's main thread is one thread. All of your server-side Lua runs on it, one resource after another. That has a direct consequence: adding cores does not make any single tick faster. A resource whose tick takes 20 ms of work still takes about 20 ms on a machine with four times the cores, because it was never able to use the second core in the first place. Single-thread clock speed does move that number, but it moves it by a fraction, not by an order of magnitude. There is no CPU that turns a 20 ms tick into a 2 ms tick. Only editing the code does that.

A machine20 ms of workon one core: the main threadFour times the coresstill about 20 mson one core: the main threadOther cores: free for the database and other work,but the tick cannot use them
The same 20 ms tick on a machine with four times the cores. The main thread runs on one core either way, so the tick takes about as long; the extra cores can take other work, such as the database, but not a share of the tick. Illustration.

What the machine genuinely decides:

  • Database performance. Query time is disk and memory bound. A MySQL instance with enough RAM to hold its working set in the buffer pool answers in a fraction of the time of one that reads from disk on every query. This is real and it is measurable.
  • Headroom. If something else on the box, a backup job, a second game server, a web panel, takes the CPU while your server needs it, your server hitches through no fault of its own.
  • Whether you swap. A server that runs out of RAM and starts swapping stalls in a way that looks exactly like bad code and is not.
  • Join and download experience. Asset delivery on join is disk and bandwidth.

Before concluding it is the machine, check it, on Linux:

Shell
vmstat 1 5
free -m

In vmstat, wa is time waiting on disk and st is time your machine wanted the CPU and did not get it. Sustained non-zero values in either column mean the machine really is the constraint. si and so moving at all means you are swapping. If all of those are near zero while your server hitches, the machine is idle and waiting on your code.

A workflow that finds it

  1. Write down the symptom in measurable terms

    "Hitches of about 300 ms, every few minutes, from around 40 players" is something you can test against. "The server lags" is not.

  2. Confirm which side and which thread

    Read the server console for hitch warnings and check the txAdmin performance chart. Client-side complaints with a clean server go to resmon, server-side warnings go to the profiler.

  3. Measure while it is happening

    Record a profile, or open resmon, during the bad period. Measurements taken at a quiet time will point you at the wrong resource.

  4. Change one thing

    Fix, restart the one resource with restart resourcename, and measure again under the same conditions. Two changes at once and you will not know which one mattered, which is also true of settings changes.

  5. Keep the measurement

    Note the before and after numbers. Performance work without numbers turns into folklore, and folklore is how a server ends up with 30 settings nobody can justify.

Need a server, or a hand with one?

Pick a plan to deploy a server, or open a ticket in the panel. Support runs 24/7/365.