Skip to main content
Flux has two encoders. Which one runs depends on whether the event has a schema.

Dynamic

WriteAny and ReadAny. One tag byte per value describing its type, then the value. Handles anything, and agrees on nothing in advance.

Schematised

schema[i].Write and .Read. No tags, because both sides already know the layout. Fixed shape, smaller payload.
The schema path applies to event payloads only. Invoke requests, invoke responses, the event name and the packet header always use the dynamic path or a fixed internal encoding. See Invocations.

Integer narrowing

The dynamic encoder inspects every number and picks the smallest representation that holds it. This happens automatically, and it is the main reason untyped Flux traffic is already reasonably compact.
Any non-integer becomes a full 8-byte f64. There is no automatic f32 narrowing, because the encoder cannot know whether you need the precision. If you send floats at volume, such as positions, rotations or normalised values, declare a schema with T.Float32() and halve them. This is the largest saving a schema offers.

Varints

Lengths and indices use a LEB128-style varint: seven bits of payload per byte, high bit set to continue. This is what makes short strings cheap. "Chat" costs one length byte plus four data bytes, where a fixed 4-byte length prefix would have doubled the overhead.
Varints encode the length of every string and buffer, the element count of every array, instance indices, and argument counts of 63 or more. They are unsigned only, so negative values are never varint-encoded.

Tables

The dynamic encoder classifies a table before writing it.
A table whose keys are exactly 1..n is written as a compact array: the tag, a varint count, then each element.
The classification walks the table once, breaking early on the first key that is not a positive integer within 1..n.
Circular references are not supported. WriteAny recurses into tables with no visited-set and no depth limit, so a table that references itself, directly or through a chain, will recurse until the Luau stack overflows.
Break cycles before sending, or send an id and let the receiver rebuild the structure.
Mixed tables lose data. { 1, 2, Name = "x" } fails the array test and takes the dictionary path, which preserves everything. But { [1] = "a", [3] = "c" } also takes the dictionary path and is reconstructed as a sparse table rather than an array. Metatables, functions and threads are never transmitted.

Instances

Instances are not serialised into the buffer at all.
1

Append to the side array

On write, the instance is pushed onto the writer’s insts array, or found in its instIndex map if it is already present in this batch.
2

Write the index

A varint index into that array goes into the buffer. One or two bytes, regardless of how deep the instance sits in the tree.
3

Ride alongside the buffer

Finalize returns (buffer, instances) and the remote is fired with both. Roblox translates the instance references natively.
This is why instance references are cheap, and why the same instance mentioned by several events in one frame costs one entry total. It is also why you get nil on the other side if the receiver cannot see the instance: the index resolves against an array whose entry Roblox declined to replicate. Always nil-check received instances.

Lossy conversions

Some datatypes are deliberately encoded narrowly. These are not round-trip exact.
Written as Position, then the rotation as an axis vector plus an angle, all f32. Reconstructed with CFrame.fromAxisAngle(axis, angle) + pos.Precision is float-level, and an axis-angle round trip will not reproduce the original matrix bit-for-bit. Fine for replication, unsuitable for anything requiring exact equality.
Each component is math.floor(c * 255) and read back with Color3.fromRGB.Colours that did not originate from 8-bit values are quantised, and the floor biases downward. Color3.new(1, 1, 1) survives, but a computed 0.5 becomes 127/255 rather than 0.5.
Written as its CFrame plus Size, but reconstructed with Region3.new(pos - size/2, pos + size/2), which is axis-aligned.Any rotation on the original region is discarded. In practice Region3 is always axis-aligned anyway, but do not rely on the CFrame surviving.
The dynamic encoder writes tostring(item), such as "Enum.KeyCode.Space", and resolves it by splitting on .. Correct and version-tolerant, but 20 or more bytes for one value.Both directions are memoised, so repeated sends of the same item skip the tostring and the lookup. Use T.Enum() in a schema to get one byte instead.
All six fields are transmitted, with the two enums using the string encoding above. Exact, but around 50 bytes. Send the parameters you actually vary instead if this is hot.

Buffer growth and pooling

4096 bytes
Every writer starts at 4 KB.
doubling
When a write would overflow, capacity doubles until it fits, and the used bytes are copied into the new buffer.
exact copy
On flush, only cursor bytes are copied into a right-sized buffer, so an oversized writer never sends padding.
512 objects, 64 KB ceiling
Writers and readers are returned to free lists holding up to 512 each. A writer that grew past 64 KB is dropped rather than pooled, so one huge payload does not permanently inflate memory.
Because the initial 4 KB comfortably holds a typical frame’s traffic, steady-state Flux performs no buffer allocation at all. Writers cycle between the pool and the bridge, and only Finalize allocates, because the remote needs a buffer it owns.

Next steps

Type tags

The full tag table and per-type byte costs.

Supported types

Everything Flux can and cannot send.