# Feeding a FiveM HUD: the data loop behind the design

> The data side of a FiveM HUD: one update loop, per-value rates, change detection, vehicle data, state bags for hunger, smooth bars and measuring cost.

Source: https://fam-tools.com/guides/fivem-hud-data-updates · FAM Tools · published 2026-09-19 · updated 2026-09-19 · about 12 min

**Short answer.** 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.

## Key points

- One loop, one message per tick, only changed fields.
- Round before comparing; 87.0001 and 87.0002 are the same speed to a player.
- Speed: `GetEntitySpeed` in m/s × 3.6 for km/h, × 2.236936 for mph.
- Server-owned stats come from state bags, not polling.
- Smoothness comes from CSS transitions, not from sending 60 times a second.
- Target well under 0.1 ms in `resmon` for the HUD resource.

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](https://fam-tools.com/guides/fivem-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

*client/hud.lua*

```lua
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.

> **Fuel depends on your fuel script** — `GetVehicleFuelLevel` returns the game’s fuel value. Many servers use a fuel resource that keeps its own value in a state bag or an export; read it from there, or your gauge will disagree with the pump.

## 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 |

*client/hud.lua — street names once per second*

```lua
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.

*client/hud.lua*

```lua
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.

*hud.js*

```js
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);
});
```

*hud.css*

```css
.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](https://fam-tools.com/guides/fivem-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.

*client/hud.lua*

```lua
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
end
```

Call `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](https://fam-tools.com/guides/fivem-hud-design-principles).

## Measuring the cost

1. Open F8 and run `resmon 1`. Your HUD resource should sit well under 0.1 ms on foot and stay low in a vehicle.
2. Open the [NUI devtools](https://fam-tools.com/guides/fivem-nui-devtools-debugging), record ten seconds in the Performance tab while driving, and look for long scripting or layout blocks.
3. Count messages: add a counter in the page’s `message` listener and log it every second. A diffed HUD sends a handful per second, not hundreds.
4. 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?

Speed around every 100–150 ms in a vehicle, health and armour every 200–300 ms, and slow values such as street names once a second. Server stats should update only on change.

### How do I convert GetEntitySpeed to km/h or mph?

It returns metres per second. Multiply by 3.6 for km/h or by 2.236936 for mph.

### Why is player health 200 in FiveM?

Multiplayer peds have a health range where 100 is dead and 200 is full. Subtract 100 for a 0–100 bar.

### How do I show hunger and thirst on my HUD?

Subscribe to your framework’s events or to state bag changes with `AddStateBagChangeHandler` and send the new value to the page when it changes.

### How do I make HUD bars move smoothly?

Update the value a few times per second and animate it with a CSS transition on `transform` that lasts about as long as the update interval.

## Related

- https://fam-tools.com/guides/fivem-hud-design-principles
- https://fam-tools.com/guides/fivem-nui-performance
- https://fam-tools.com/guides/fivem-sendnuimessage-patterns
- https://fam-tools.com/guides/fivem-notification-ui
