Sending data to NUI: SendNUIMessage done properly
SendNUIMessage(table) is a Lua convenience wrapper that runs json.encode and calls the native SendNuiMessage(string). The page receives it as a message event with the decoded object in event.data. Route every message with one field such as action, wait for a “ready” callback before sending initial data, and know the encoder: empty tables become [], arrays with holes may become objects, and vectors must be converted to plain tables. Send large data once and small updates often, and use SendLoadingScreenMessage for the loading screen frame.
Lua to page is the busiest direction in any FiveM UI. Inventories push item lists, HUDs push numbers several times a second, phones push notifications. SendNUIMessage looks trivial, but the way it encodes tables, the moment you call it and the size of what you send decide whether the UI is solid or full of odd bugs that only appear on some players’ machines.

#SendNUIMessage vs SendNuiMessage
The native is SendNuiMessage, and it takes a JSON string. The Lua runtime adds SendNUIMessage (capital NUI), which takes a table, encodes it and calls the native. JavaScript and C# client scripts only have the native, so they encode themselves.
| Runtime | Call |
|---|---|
| Lua | SendNUIMessage({ action = 'open' }) |
| Lua, pre-encoded | SendNuiMessage(json.encode({ action = 'open' })) |
| JavaScript | SendNuiMessage(JSON.stringify({ action: 'open' })) |
| C# | API.SendNuiMessage(JsonConvert.SerializeObject(new { action = "open" })) |
Messages only reach the ui_page of the resource that sends them. There is no way to call SendNUIMessage from the server; trigger a client event and send from there. To message another resource’s UI, call an export or event in that resource and let it send.
#Routing with one listener
A UI with several features quickly ends up with many message types. One listener with a lookup table keeps it readable and makes unknown messages visible during development.
type Message =
| { action: 'visible'; value: boolean }
| { action: 'setItems'; items: { name: string; count: number }[] }
| { action: 'updateItem'; name: string; count: number };
const handlers: { [K in Message['action']]: (msg: Extract<Message, { action: K }>) => void } = {
visible: (m) => document.body.classList.toggle('open', m.value),
setItems: (m) => renderAll(m.items),
updateItem: (m) => renderOne(m.name, m.count),
};
window.addEventListener('message', (event: MessageEvent<Message>) => {
const msg = event.data;
const handler = handlers[msg?.action] as ((m: Message) => void) | undefined;
if (handler) handler(msg);
else console.warn('Unhandled NUI message', msg);
});The discriminated union gives you type checking on every field in the handlers. The same idea in React is a useNuiEvent(action, handler) hook, covered in the React NUI boilerplate, and in Svelte or Vue in Svelte and Vue NUI with Vite.
#Timing: the ready handshake
A client script starts running before its NUI page has finished loading, especially on first join when the page and its bundle are still being fetched. Messages sent in that window can be missed because no message listener exists yet. The symptom is a UI that works after restart, but shows stale or empty data on a fresh connect.
The robust fix is to let the page announce itself. The page calls a ready callback once its listener is attached, and Lua sends the initial state only after that.
window.addEventListener('message', onMessage);
fetch(`https://${GetParentResourceName()}/ready`, { method: 'POST', body: '{}' });local uiReady = false
local pending = {}
local function send(msg)
if uiReady then
SendNUIMessage(msg)
else
pending[#pending + 1] = msg
end
end
RegisterNuiCallback('ready', function(_, cb)
uiReady = true
for i = 1, #pending do SendNUIMessage(pending[i]) end
pending = {}
cb({})
end)Use send everywhere instead of calling SendNUIMessage directly. For state that is replaced rather than accumulated (settings, the player’s job), keep only the latest message per action in pending instead of a list.
#JSON encoder quirks in Lua
FiveM’s Lua json module is based on dkjson. It decides between a JSON array and a JSON object by inspecting the table’s keys, and a few of its decisions surprise people coming from JavaScript.
| Lua value | JSON produced | What to do |
|---|---|---|
{} | [] | If the page expects an object, use setmetatable({}, { __jsontype = 'object' }) or check Array.isArray on the page |
{ 'a', 'b' } | ["a","b"] | Fine |
{ [1] = 'a', [3] = 'c' } | ["a",null,"c"] | Small holes become null; filter them out on the page |
{ [1] = 'a', [50] = 'z' } | {"1":"a","50":"z"} | Large holes become an object with string keys; send a real list instead |
{ name = nil } | [] | Keys with nil values vanish entirely; use json.null if the page needs the key |
vector3(1, 2, 3) | Not a plain object | Send { x = v.x, y = v.y, z = v.z } |
| A function or a table that references itself | Encoding error | Never send them |
#Payload size and frequency
Every message is a string encoded in Lua, passed to the browser process and parsed again in JavaScript. A few hundred bytes cost nothing. A multi-kilobyte inventory sent every frame costs real frame time in both Lua and the browser, and shows up in resmon and the devtools Performance tab.
- Send full state on open, diffs afterwards. The inventory list goes once when the UI opens; after that, send
updateItemwith one item. - Never send images as base64. Send a file name or
https://cfx-nui-…URL and let the browser cache the image. Paths are covered in NUI asset paths. - Round numbers before sending. Speed as
87instead of87.123456789is smaller and stops the UI from re-rendering on meaningless changes. - Skip unchanged values. If health did not change since the last send, do not send it. The pattern is shown in the HUD data loop.
- Hidden UI, no messages. When the UI is closed, stop pushing updates it cannot show.
#Messages to the loading screen
The loading screen lives in its own frame and does not receive SendNUIMessage. FiveM provides SendLoadingScreenMessage(jsonString) for that, which returns whether it succeeded. It takes a JSON string, like the raw native, and the loading screen receives it through its normal message listener.
AddEventHandler('playerSpawned', function()
SendLoadingScreenMessage(json.encode({ eventName = 'characterReady' }))
end)This is useful together with manual loading screen shutdown: the screen stays up while your multicharacter resource prepares, and you can update its status text before calling ShutdownLoadingScreenNui(). Pick event names that do not collide with FiveM’s own loading events such as loadProgress.
#When messages do not arrive
| Symptom | Check |
|---|---|
| Nothing happens at all | Is the listener attached? Dispatch a test message from the devtools console |
| Works after restart, not on join | Timing; add the ready handshake |
Handler gets undefined fields | Key names differ in case (Action vs action) or the value was nil in Lua |
| Object expected, array received | Empty Lua table encoded as [] |
| UI stutters while open | Too many or too large messages; send diffs and fewer per second |
The devtools at localhost:13172 let you log every incoming message with one line in the console (addEventListener('message', e => console.log(e.data))). See NUI devtools.
#Batching bursts of events
Some systems produce many small updates at once: a trunk search that changes twenty items, a server sync that sets every job grade, a notification flood when a heist starts. Sending one message per change is correct but wasteful. Collect changes during the frame and flush them together.
local batch = {}
local scheduled = false
local function queueUpdate(name, value)
batch[name] = value
if scheduled then return end
scheduled = true
SetTimeout(0, function()
scheduled = false
local items = batch
batch = {}
send({ action = 'updateItems', items = items })
end)
endBecause batch is keyed by item name, three changes to the same item in one frame collapse into the latest value. The page receives one message and renders once. The same trick works in the other direction: if a slider sends a callback on every input event, debounce it on the page so Lua receives the final value rather than fifty intermediate ones.
Static data belongs in the same category. Item labels, images and descriptions do not change during a session, so send them once after ready (or ship them as a JSON file listed in files and load it with fetch) and afterwards send only names and counts.
Questions
What is the difference between SendNUIMessage and SendNuiMessage?
SendNuiMessage is the native and takes a JSON string. SendNUIMessage is a Lua helper that takes a table and encodes it for you.Can I send an NUI message from the server?
SendNUIMessage on the client.Why does my empty table arrive as an array?
__jsontype = 'object' or normalise it on the page.Why is my NUI empty on first join but fine after a restart?
ready callback and send initial data after that.How do I send data to the loading screen?
SendLoadingScreenMessage(json.encode({...})); the loading screen receives it as a message event.Ready to pick a map?
Twelve themes on three base map styles, $8 each, instant download.
Relevant to what you just read
The tool for this
A drag-and-drop loading screen, hosted for you From Free, built in the browser, exported as a drop-in resource.