NUI and CEFintermediate11 min readupdated 19 Sept 2026

Sending data to NUI: SendNUIMessage done properly

The short answer

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.

Sending data to NUI: SendNUIMessage done properly — illustrated with a FiveM-compatible minimap render

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

RuntimeCall
LuaSendNUIMessage({ action = 'open' })
Lua, pre-encodedSendNuiMessage(json.encode({ action = 'open' }))
JavaScriptSendNuiMessage(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.

messages.ts
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.

app.js — at the end of startup
window.addEventListener('message', onMessage);
fetch(`https://${GetParentResourceName()}/ready`, { method: 'POST', body: '{}' });
client.lua
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 valueJSON producedWhat 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 objectSend { x = v.x, y = v.y, z = v.z }
A function or a table that references itselfEncoding errorNever 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 updateItem with 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 87 instead of 87.123456789 is 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.

client.lua — tell the loading screen the character is ready
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

SymptomCheck
Nothing happens at allIs the listener attached? Dispatch a test message from the devtools console
Works after restart, not on joinTiming; add the ready handshake
Handler gets undefined fieldsKey names differ in case (Action vs action) or the value was nil in Lua
Object expected, array receivedEmpty Lua table encoded as []
UI stutters while openToo 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.

client.lua — one message per frame at most
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)
end

Because 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.

Share this guideXRedditFacebook

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?
No. Trigger a client event from the server and call SendNUIMessage on the client.
Why does my empty table arrive as an array?
FiveM’s Lua JSON encoder treats a table with no keys as an array. Mark it with __jsontype = 'object' or normalise it on the page.
Why is my NUI empty on first join but fine after a restart?
The message was sent before the page had loaded. Have the page call a ready callback and send initial data after that.
How do I send data to the loading screen?
Use 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.