Skip to content

Bandwidth

Every byte a schema sends is decided by its types. This page explains how those bytes are laid out, with worked counts, so you can see what a change to the schema will cost before you make it.

The counts here come from the compiler’s own size analysis, which the test suite checks against the bytes the generated serialisers actually write.

Reliable traffic is batched. Every reliable event and function call fired during a frame is written into one buffer per recipient – one per player on the server, one on the client – and the buffer is sent once, on Heartbeat. With ManualReplication you choose when that happens by calling StepReplication yourself. The flush keeps its buffer for the next frame rather than growing one again from 64 bytes.

Unreliable traffic is not, by default. Each unreliable Fire is sent at once, as a packet of its own. BatchUnreliable gathers a frame’s unreliable events into as few packets as the unreliable limit allows, sent at the flush – one remote call and one packet header for a frame of small events, arriving no later than events sent at once, since Roblox sends a frame’s remote calls together. Streams are always gathered this way, since they are sent at the flush anyway. A reliable FireAll goes to every player in one FireAllClients at the flush, whatever the option.

Inside a packet, each item carries a small header in front of its payload:

Item Header
Reliable or Unreliable event 1 byte: the event’s index
OrderedUnreliable event 3 bytes: the index and a 2-byte sequence number
Stream 1 byte: the index; the stamp every packet carries orders it
Function call 2 bytes: the index and the call’s id
Function reply 3 bytes: the index, the call’s id and a success flag; a failed call’s reply is those 3 bytes alone

Instance and unknown values do not go into the buffer at all. They travel beside it, in the remote’s second argument, and are serialised by Roblox. They cost no buffer bytes, but they are not free, and they count against MaxInstancesPerPacket.

A boolean is one bit, not one byte. So is the presence flag of an optional (T?). Every boolean and every presence flag in the same block of the payload is packed into a shared bitfield, eight to a byte. A struct is not a block of its own: its fields share the bitfield of whatever contains them.

Flags.blink
struct Stance {
Crouching: boolean,
Sprinting: boolean,
Aiming: boolean,
Target: u8?,
Weapon: u8?
}

Three booleans and two presence flags are five bits, so Stance is one byte of flags plus whichever of the two u8s are present: 1 to 3 bytes. Before 0.21.0 each of those five took a byte of its own.

A new block starts where the generated code branches or loops, and bits do not cross into it:

  • each element of an array is a block, so booleans inside an array element share a byte per element;
  • an optional’s payload is a block, so the optional costs one bit in its parent and its own bits start fresh inside;
  • each variant of a tagged enum is a block;
  • a map’s key and value share one block per entry.

A plain boolean[] is packed eight elements to a byte after its length, rather than one bit-byte per element:

Type Bytes
boolean[1000] 125
boolean[..1000] 2 to 127 (a 2-byte length, then 1 byte per 8 elements)
boolean?[..1000] 2 to 2002 – not packed, since each element carries a presence bit as well as its value

A string, a buffer or an array carries its length in front of its contents. The length is written relative to the declared minimum, in the smallest unsigned integer that holds the span between the two bounds. An exact length needs no prefix at all, since the reader already knows it.

Type Length prefix Total bytes
string 2 (the default ceiling is 65535) 2 to 65537
string(0..64) 1 1 to 65
string(0..400) 2 2 to 402
string(300..400) 1 (the span is 100) 301 to 401
string(36) none 36
u8[] 2 2 to 65537
u8[..16] 1 1 to 17
u8[4] none 4
map { [u8]: u8 } 2, always 2 and up

So bounding a field does two things: it lets the receiver refuse a value outside the bound before reading it (see Securing the server), and it usually shaves a byte off every send. A bound above 65535 (string(0..100000)) takes a 4-byte prefix.

A map’s count is always a u16 and a map cannot be bounded; a bounded array of key-value structs costs the same per entry and states its ceiling.

A range on a number is a check, not an encoding: u32(0..10) still takes four bytes, and the receiver refuses anything outside 0..10. Choose the narrowest type that holds every value you send, then add the range you mean. The one exception is a quantized float: f32<0.01>(-1..1) names a precision and a range, and BlinkBlox sends it in the fewest bytes that hold them – one, here.

Type Bytes Holds
u8, i8 1 0 to 255, -128 to 127
u16, i16 2 0 to 65535, -32768 to 32767
u24, i24 3 0 to 16777215, -8388608 to 8388607
u32, i32 4 0 to about 4.29 billion, about +-2.1 billion
f16 2 about +-65504, with 11 significant bits
f24 3 about +-4.29e9, with 18 significant bits, rounded to nearest
f32 4 Luau’s Vector3 precision
f64 8 a Luau number, exactly
enum { ... } 1 to 8 bits up to 256 values, in the bits that tell them apart
set { ... } 1, 2 or 4 per 32 flags 1 byte up to 8 flags, 2 up to 16, 4 up to 32
boolean 1 bit

f16 is half the size of f32 and loses precision fast as the magnitude grows. Every integer up to 2048 is exact; between 512 and 1024 the step is 0.5, between 1024 and 2048 it is 1, and near the top of its range it is 32. It suits directions, normalised values, small offsets and velocities. It does not suit world positions, where a character a thousand studs from the origin would move in half-stud steps.

f24 is the width for world positions. It keeps 18 significant bits and rounds to nearest: every integer up to 262144 is exact, and a value between 512 and 1024 arrives within 0.002 of itself, between 8192 and 16384 within 0.032. vector<f24> is 9 bytes where vector is 12.

An enum costs the bits that tell its values apart however long its names are – two for four values – and shares a byte with the booleans beside it. A state sent as string costs its length plus a prefix every time.

A vector’s component type sets its size, and a CFrame has one component type for its rotation (the first) and one for its position (the last):

Type Bytes Notes
vector 12 three f32
vector<f24> 9 see f24 above
vector<f16> 6 see f16 above
vector<i16> 6 whole numbers only, each within -32768..32767
CFrame 24 f32 position, three f32 Euler angles
CFrame<f16, f32> 18 f16 rotation, f32 position
CFrame<quat> 19 f32 position, 7-byte quaternion rotation
CFrame<quat, f24> 16 f24 position, 7-byte quaternion rotation
CFrame<quat, f16> 13 f16 position, 7-byte quaternion rotation

CFrame<quat> encodes the rotation as the three smallest components of a unit quaternion, in 7 bytes instead of 12, to within about 0.002 degrees. It is opt-in because it is lossy: it suits characters, projectiles and cameras, not anything that compares rotations for equality. An integer rotation type keeps only whole radians and is almost never what you want. See CFrames.

The same player state, written twice:

PlayerState.blink
struct Naive {
Health: f64,
Stamina: f64,
Crouching: boolean,
Sprinting: boolean,
Weapon: string?,
Pose: CFrame
}
struct Tuned {
Health: u8(0..100),
Stamina: u8(0..100),
Crouching: boolean,
Sprinting: boolean,
Weapon: enum { Pistol, Rifle, Knife }?,
Pose: CFrame<quat>
}
Field Naive Tuned
Health, Stamina 16 2
Crouching, Sprinting, Weapon’s presence 1 (three bits) 1 (three bits)
Weapon = "Rifle" 7 (2-byte prefix and 5 characters) 1
Pose 24 19
Total 48 23

Sent as a reliable event, add one byte for the index. At twenty updates a second to thirty players, that difference is about 15 KB a second of server upload.

Roblox drops an UnreliableRemoteEvent whose payload is over 1000 bytes. Studio’s Output says so, but a live game drops the event silently, and only when a value happens to be large. The payload is everything the call carries, as Roblox encodes it: BlinkBlox’s buffer, the table of instances beside it, and the bytes describing each. Measured in Studio, a buffer alone arrives up to 994 bytes, and each Instance beside it costs 6 bytes more. The compiler measures every Unreliable and OrderedUnreliable event, header included, as its buffer plus 6 bytes per Instance, against MaxUnreliableSize. It is 980 by default, which keeps 14 bytes in reserve:

  • an event whose smallest possible payload is over the limit is an error, E3018;
  • an event whose largest possible payload is over the limit is a warning, W3019, which names the field that has no upper bound.

At runtime every unreliable send is measured again, and one that has grown past the limit is dropped with a warning naming the event, rather than vanishing.

Snapshot.blink
event Snapshot {
From: Server,
Type: Unreliable,
Call: SingleSync,
Data: struct {
Tick: u32,
Players: struct { Id: u8, Position: vector<f16>, Crouching: boolean }[..64]
}
}

Snapshot is at most 518 bytes: the index, 4 for Tick, a 1-byte count, and 64 players of 8 bytes each (Id, three f16, and a byte holding Crouching’s bit). Write [] instead of [..64] and it compiles with W3019, because the array could then hold 65535 players. A fixed payload that can never fit is refused:

Refused
event Blob {
From: Server,
Type: Unreliable,
Call: SingleAsync,
Data: u8[1000]
}

OrderedUnreliable costs two bytes more than Unreliable per packet, for the sequence number that lets the receiver discard a packet older than one it has already seen. Use it for state – a position, an aim direction – where a stale packet would make something visibly snap back, and plain Unreliable for independent signals.

Everything above predicts a cost; option TrafficStats measures it. Roblox’s Stats.DataSendKbps is the whole game’s traffic, so it cannot say how much of it is BlinkBlox’s or which event costs the most. With the option on, GetTrafficStats() can:

TrafficReport.server.luau
local Net = require(ServerScriptService.Network.Server)
local Last = Net.GetTrafficStats()
while true do
task.wait(60)
local Now = Net.GetTrafficStats()
for Name, Counts in Now.Events do
local Bytes = Counts.SentBytes - Last.Events[Name].SentBytes
if Bytes > 0 then
print(`{Name}: {Bytes // 60} bytes a second, {(Counts.Sent - Last.Events[Name].Sent) / 60} a second`)
end
end
print(`reliable packets: {(Now.Reliable.Sent - Last.Reliable.Sent) / 60} a second`)
Last = Now
end

Sort by bytes, and the top of the list is where a narrower type, a range or a lower send rate pays most. The counts are the buffer’s bytes before Roblox compresses a packet and adds its own headers, so they will not add up to DataSendKbps: they say where BlinkBlox’s share goes, not what the network card sees. A server event’s bytes are counted once per player it reaches – a FireAll to twenty players is twenty sends – because that is what each of them costs. The full rules are on the Generated API page.

The counting is a few integer additions per event, measured at a few percent of the time spent encoding and decoding, so it can stay on in a live game. Turning the option off removes every line of it.

  • Bound every string, buffer and array. It is a smaller prefix and a check the server gets for free.
  • Use u8/u16 and a range for counts, health, ammo and ids; f64 only for values that need it.
  • Use an enum for anything that is one of a known set of names.
  • Group booleans and optionals in one struct; they share bytes.
  • Use vector<f16> for directions, vector<f24> for world positions, and CFrame<quat> for poses that do not need to be exact.
  • Keep unreliable payloads bounded well under MaxUnreliableSize.
  • Prefer one event with a struct over several events fired together: each event costs an index byte.

An array of 1000 booleans is 128 bytes from BlinkBlox’s encoder, where Blink, zap, ByteNet and Packet write 1003, because boolean[] is packed eight to a byte. QuickNet and Warp pack a boolean array eight to a byte too – QuickNet writes a length’s last one to seven elements a byte each – and spend a byte on a boolean field of a struct, where BlinkBlox packs those too. With a different random array in every event, measured in Studio on 0.41.2 after Roblox’s compression, that is 128.4 bytes an event for BlinkBlox, 128.1 for QuickNet and 128.6 for Warp, and about 194 for the others: the compressor finds some of the bits a byte per boolean wastes, not all of them. Structs of plain u8 fields cost every tool the same, give or take a byte of length. Benchmarks has the full numbers and how they were measured.