Feeding a FiveM HUD: the data loop behind the design
Use one client thread that wakes a few times per second, reads every value, compares it with what was last sent and sends a single message containing only the changes. Read fast-changing values such as speed more often (every 100–150 ms in a vehicle) and slow ones such as street names rarely (every second). Get server-owned values like hunger and thirst from state bags with AddStateBagChangeHandler instead of polling. Round numbers before comparing, animate between values in CSS so the page looks smooth at low update rates, and stop sending entirely while the HUD is hidden.
A good-looking HUD with a bad data loop is the classic source of “this server drops my FPS”. The design decides what players see; the loop decides how often Lua reads natives, how many messages cross into the browser and how often the page re-renders. This guide is the engineering half of HUD design principles: a structure that reads each value at a sensible rate, sends only what changed, and keeps both resmon and the browser quiet.

#The shape of a good loop
Most slow HUDs have the same structure: several threads, each with Wait(0), each calling SendNUIMessage every frame with the full state. At 100 FPS that is hundreds of JSON encodes and browser messages per second for numbers that barely change. The alternative is almost the opposite in every respect.
| Slow pattern | Fast pattern |
|---|---|
A thread per value, Wait(0) | One thread, Wait(100–250) |
| Full state every tick | Only fields that changed |
| Raw floats | Rounded values |
| Polling hunger from the framework every frame | State bag change handler |
| Updating while the pause menu is open | Skipping updates while hidden |
| Page animates by receiving many values | Page animates with CSS between values |
#One loop with change detection
local last = {}
local function collect(ped)
local data = {
health = math.max(0, GetEntityHealth(ped) - 100),
armour = GetPedArmour(ped),
talking = NetworkIsPlayerTalking(PlayerId()),
underwater = IsPedSwimmingUnderWater(ped),
}
local vehicle = GetVehiclePedIsIn(ped, false)
data.inVehicle = vehicle ~= 0
if data.inVehicle then
data.speed = math.floor(GetEntitySpeed(vehicle) * 3.6 + 0.5)
data.fuel = math.floor(GetVehicleFuelLevel(vehicle) + 0.5)
data.gear = GetVehicleCurrentGear(vehicle)
end
return data
end
local function diff(data)
local changes, any = {}, false
for key, value in pairs(data) do
if last[key] ~= value then
changes[key] = value
last[key] = value
any = true
end
end
return any and changes or nil
end
CreateThread(function()
while true do
local ped = PlayerPedId()
local changes = diff(collect(ped))
if changes then
SendNUIMessage({ action = 'hud', data = changes })
end
Wait(IsPedInAnyVehicle(ped, false) and 120 or 250)
end
end)A player ped’s health runs from 100 (dead) to 200 (full) in multiplayer, which is why the loop subtracts 100 to get a 0–100 bar. If your framework raises max health, divide by GetEntityMaxHealth(ped) - 100 instead. The adaptive Wait reads faster when a speedometer is on screen and slower on foot, where nothing changes quickly.
#Picking a rate for each value
Not everything deserves the same frequency. Split the loop into tiers by keeping a counter, or run two threads: a fast one for vehicle data and a slow one for everything else.
| Value | Suggested interval | Source |
|---|---|---|
| Speed, RPM, gear | 100–150 ms in a vehicle | GetEntitySpeed, GetVehicleCurrentRpm, GetVehicleCurrentGear |
| Health, armour | 200–300 ms | GetEntityHealth, GetPedArmour |
| Voice activity | 150–250 ms | NetworkIsPlayerTalking or your voice resource |
| Fuel, engine health | 1 s | GetVehicleFuelLevel, GetVehicleEngineHealth |
| Street and zone name | 1 s | GetStreetNameAtCoord, GetNameOfZone |
| Hunger, thirst, stress | On change only | State bags or framework events |
| Job, cash, bank | On change only | Framework events |
CreateThread(function()
while true do
local coords = GetEntityCoords(PlayerPedId())
local streetHash, crossingHash = GetStreetNameAtCoord(coords.x, coords.y, coords.z)
local street = GetStreetNameFromHashKey(streetHash)
local crossing = crossingHash ~= 0 and GetStreetNameFromHashKey(crossingHash) or nil
local zone = GetLabelText(GetNameOfZone(coords.x, coords.y, coords.z))
local changes = diff({ street = street, crossing = crossing or '', zone = zone })
if changes then
SendNUIMessage({ action = 'hud', data = changes })
end
Wait(1000)
end
end)Both threads share the same last table and diff function, so each field is compared against what the page actually has. GetNameOfZone returns a short code such as DOWNT; GetLabelText turns it into the readable name.
#Server-owned values with state bags
Hunger, thirst and stress usually live on the server, and frameworks increasingly store them in player state bags. Polling them wastes time; subscribing is free until something changes.
local bagName = ('player:%s'):format(GetPlayerServerId(PlayerId()))
for _, key in ipairs({ 'hunger', 'thirst', 'stress' }) do
AddStateBagChangeHandler(key, bagName, function(_, changedKey, value)
if type(value) ~= 'number' then return end
local rounded = math.floor(value + 0.5)
if last[changedKey] ~= rounded then
last[changedKey] = rounded
SendNUIMessage({ action = 'hud', data = { [changedKey] = rounded } })
end
end)
end
CreateThread(function()
local state = LocalPlayer.state
SendNUIMessage({ action = 'hud', data = {
hunger = state.hunger or 100,
thirst = state.thirst or 100,
stress = state.stress or 0,
} })
end)The handler receives the bag name, the key and the new value; the old value is still in the bag at that moment. Filtering by the local player’s bag name means you only hear about your own stats. The initial send covers values that were set before the handler was registered. If your framework uses different key names or plain events, subscribe to those instead; the principle is the same.
#The page side: apply, do not rebuild
On the page, merge each update into your state and touch only the elements that changed. In plain JavaScript that means updating a CSS variable or a text node, not rebuilding the HUD’s HTML.
const root = document.documentElement;
const text = {
speed: document.getElementById('speed'),
street: document.getElementById('street'),
};
window.addEventListener('message', (e) => {
if (e.data.action !== 'hud') return;
const d = e.data.data;
if ('health' in d) root.style.setProperty('--health', d.health / 100);
if ('armour' in d) root.style.setProperty('--armour', d.armour / 100);
if ('hunger' in d) root.style.setProperty('--hunger', d.hunger / 100);
if ('speed' in d) text.speed.textContent = d.speed;
if ('street' in d) text.street.textContent = d.street;
if ('inVehicle' in d) document.body.classList.toggle('in-vehicle', d.inVehicle);
if ('talking' in d) document.body.classList.toggle('talking', d.talking);
});.bar-fill {
transform-origin: left center;
transform: scaleX(var(--health, 1));
transition: transform 250ms linear;
}
.speedo {
display: none;
}
.in-vehicle .speedo {
display: block;
}Animating transform rather than width keeps the work on the compositor, and a transition as long as the update interval makes a 4-updates-per-second bar look continuous. The same idea applies in React, Svelte or Vue: keep the HUD state in one store and let each gauge subscribe to its own field so a speed change does not re-render the street name. More CSS guidance is in NUI performance.
#Stop working while hidden
A HUD that is hidden during the pause menu, cutscenes or character creation should also stop its loop. Checking a few natives each tick costs far less than encoding and rendering updates nobody sees.
local hidden = false
local function shouldHide()
return IsPauseMenuActive() or IsScreenFadedOut() or IsPlayerSwitchInProgress()
end
local function updateVisibility()
local hide = shouldHide()
if hide ~= hidden then
hidden = hide
SendNUIMessage({ action = 'visible', value = not hide })
if not hide then
last = {}
end
end
return not hide
endCall updateVisibility() at the top of the loop and skip the collection when it returns false. Clearing last when the HUD reappears forces a full resend, so the page never shows values from before the pause. Which moments to hide for is a design decision, discussed in HUD design principles.
#Measuring the cost
- Open F8 and run
resmon 1. Your HUD resource should sit well under 0.1 ms on foot and stay low in a vehicle. - Open the NUI devtools, record ten seconds in the Performance tab while driving, and look for long scripting or layout blocks.
- Count messages: add a counter in the page’s
messagelistener and log it every second. A diffed HUD sends a handful per second, not hundreds. - Test with the worst case: a fast car, voice active, and every HUD element visible.
If the numbers are high, the fix is almost always in this order: fewer threads, longer waits, diffing, then CSS. Rewriting the UI in a different framework rarely helps until the loop is right.
Questions
How often should a FiveM HUD update?
How do I convert GetEntitySpeed to km/h or mph?
Why is player health 200 in FiveM?
How do I show hunger and thirst on my HUD?
AddStateBagChangeHandler and send the new value to the page when it changes.How do I make HUD bars move smoothly?
transform that lasts about as long as the update interval.Ready to pick a map?
Twelve themes on three base map styles, $8 each, instant download.
Relevant to what you just read
Look it up while you work
The reference tables this guide leans on, free and without an account.
- All 921 vehicle spawn namesEvery vanilla model with a picture, its display name, its class and the hash CreateVehicle takes.
- Every GTA 5 zone nameAll 291 popzones across 44 districts, with the ids GET_NAME_OF_ZONE and GET_ZONE_FROM_NAME_ID use.
- The same tables as JSON and CSVFree downloads, CORS open, no account and no API key — ship the list inside your own resource instead of scraping a page.