FiveM State Bags Explained: Replicating Custom Data Across Clients Without Wrecking Performance
State bags are FiveM’s built-in mechanism for attaching replicated key-value data to entities, players, or the global game state. If you’ve been syncing custom data by firing TriggerClientEvent to all players every time something changes, state bags are the cleaner alternative. They’re not magic — misuse them and you’ll push unnecessary data across the wire to every connected client. This covers the three scopes, the write API, change handlers, and the real tradeoffs between state bags and plain events.
The Three Scopes
State bags come in three distinct scopes, and picking the wrong one is the most common mistake.
Entity State
Entity(netId).state attaches data to a networked entity identified by its network ID. The data travels with the entity and is only visible to clients that have it in streaming range. This is the right scope for per-vehicle or per-ped properties — fuel level, faction assignment, a weapon modifier. On the client, retrieve the net ID with NetworkGetNetworkIdFromEntity, then read it as Entity(netId).state.yourKey.
Player State
Player(serverId).state attaches data to a player by their server ID. Unlike entity state, this is not scope-gated by streaming — all clients receive it regardless of proximity. Use it for data every client legitimately needs: a job name, a duty flag, a display rank. Because it replicates globally, keep the payload small and the update frequency low.
Global State
GlobalState is a single shared bag with no owner. It replicates to every client unconditionally. Good candidates are server-wide toggles, a current event phase, or a shared timer. Anything that changes more than a few times per minute or carries data most clients don’t need does not belong here.
Setting Values and the Replicated Boolean
The write API on all three scopes is :set(key, value, replicated). The third argument is the one most developers set carelessly. When replicated is true, the runtime serializes the value and broadcasts it to clients in scope. When it’s false, the value is server-local only — readable on the server, invisible to clients. If you’re storing internal bookkeeping data that only server scripts ever read, pass false. Passing true unnecessarily is the easiest way to bleed bandwidth on state bags.
You can assign directly — Entity(netId).state.fuel = 80 — but this always defaults to non-replicated. Explicit :set calls are clearer and avoid surprises when you need to flip replication later.
Reading State on the Client
Reads are synchronous against a local cache the runtime keeps current. Entity(netId).state.fuel returns the current value or nil if unset. Player(GetPlayerServerId(PlayerId())).state.job reads your own player bag. No round-trip involved. You can read these in a tick loop, but you generally shouldn’t — prefer reacting to changes via handlers instead.
Recommended FiveM scripts for your server
Reacting to Changes with AddStateBagChangeHandler
AddStateBagChangeHandler is available on both client and server. It takes a key name, an optional bag filter (an entity bag name, or nil to match all bags), and a callback. When the named key changes on any matching bag, your callback fires with the bag name, key, value, a reserved integer, and a replicated flag.
On the server, the handler fires for every write regardless of replication. On the client, it fires only for replicated writes. If your client-side handler never triggers, check that you called :set with replicated = true on the server side — no error is thrown when this is wrong, data simply doesn’t arrive.
How State Bags Replaced TriggerClientEvent Spam
The old pattern: change a value on the server, call TriggerClientEvent with -1 as the target, broadcast to everyone. Every property update dispatches a full event delivery to every connected player. State bags compress that. The runtime batches updates and sends each client only the bags it has in scope — entity bags only reach clients streaming that entity. Your server code becomes a single :set call. Clients react via AddStateBagChangeHandler instead of a RegisterNetEvent + handler pair. A client that streams in an entity after the value was set sees the correct state immediately, with no replay needed — events can’t do that.
Performance Pitfalls
- High-frequency writes: Calling :set with replicated = true ten times per second on a player bag floods every client. Values that change every server tick — a real-time counter, a smoothed position offset — are not state bag candidates. Use native sync or client-side interpolation instead.
- Large table values: Values are serialized to MsgPack. A deeply nested table is serialized and transmitted in full on every write, even if one field changed. Break large tables into individual keys so only changed data travels.
- Over-replicating player bags: Player bags broadcast globally. If ninety percent of clients have no use for a value, consider entity bags or a targeted TriggerClientEvent to the one player who needs it.
- Orphaned bags: Deleted entities don’t automatically drop their state. On long-running servers, stale bags accumulate. Clean up explicitly on the entityRemoved event, or call :set(key, nil, false) before entity deletion.
Upgrade your server — shop our FiveM scripts
State Bags vs Plain Events: Choosing the Right Tool
Use plain events for transient notifications — a one-time popup, a sound cue, a UI flash. Fire-and-forget is fine when persistence doesn’t matter. Use state bags for ongoing state any future reader needs to see correctly without asking the server to resend it.
For framework-level patterns, the resources on qb-tebex.io show how high-traffic QBCore servers distribute player state without hammering the event system. For resources built around minimal tick overhead, 0resmon’s catalog demonstrates disciplined replication in practice. If you want scripts where the state bag usage has already been reviewed before it hits your server, cfx-tebex.store is a reasonable starting point — less time auditing third-party code, more time building.