# Sending data to NUI: SendNUIMessage done properly

> SendNUIMessage vs SendNuiMessage, routing by action, the ready handshake, Lua JSON quirks, payload size and messages to the FiveM loading screen.

Source: https://fam-tools.com/guides/fivem-sendnuimessage-patterns · FAM Tools · published 2026-09-19 · updated 2026-09-19 · about 11 min

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

## Key points

- `SendNUIMessage` = `json.encode` + `SendNuiMessage`.
- One routing field (`action`) and one listener on the page.
- Wait for the page to say it is ready before sending startup data.
- Empty Lua tables encode as `[]`, not `{}`.
- Convert `vector3` to `{ x, y, z }` before sending.
- Send full state rarely and small diffs often.

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.

*messages.ts*

```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](https://fam-tools.com/guides/fivem-nui-react-boilerplate-structure), and in Svelte or Vue in [Svelte and Vue NUI with Vite](https://fam-tools.com/guides/fivem-nui-svelte-vue-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*

```js
window.addEventListener('message', onMessage);
fetch(`https://${GetParentResourceName()}/ready`, { method: 'POST', body: '{}' });
```

*client.lua*

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

> **Item lists keyed by name** — Inventories often store items as `items['water'] = 3`. That encodes as an object, which is fine, but an empty inventory encodes as `[]`. Either always send a list of `{ name, count }` records, or normalise on the page with `Array.isArray(x) ? {} : x`.

## 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](https://fam-tools.com/guides/fivem-nui-assets-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](https://fam-tools.com/guides/fivem-hud-data-updates).
- **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*

```lua
AddEventHandler('playerSpawned', function()
    SendLoadingScreenMessage(json.encode({ eventName = 'characterReady' }))
end)
```

This is useful together with [manual loading screen shutdown](https://fam-tools.com/guides/fivem-loadscreen-manual-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](https://fam-tools.com/guides/fivem-nui-devtools-debugging).

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

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

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

## Related

- https://fam-tools.com/guides/fivem-nui-basics
- https://fam-tools.com/guides/fivem-getparentresourcename-nui-callbacks
- https://fam-tools.com/guides/fivem-hud-data-updates
- https://fam-tools.com/guides/fivem-nui-performance
- https://fam-tools.com/guides/fivem-loadscreen-manual-shutdown
