Skip to main content
This module is internal. It is documented so you can reason about how Flux behaves and performs, but it is not re-exported from the root Flux module and its surface may change without notice. For everyday use, reach for Flux.Server and Flux.Client.
All wire I/O lives here. Compiled with --!strict --!native --!optimize 2, and every buffer and bit32 function is localised at the top of the module.
This is the one internal module you have a legitimate reason to touch, because writing a custom schema type means calling these primitives directly.

Types

buffer
The backing store. Reassigned on the writer when it grows.
number
Current byte offset. Advanced by every read and write.
number
On a writer, the allocated capacity. On a reader, the buffer’s actual length, used as the loop bound when decoding a batch.
{ Instance }
The side array carrying instance references out of band.
{ [Instance]: number }
Writer only. Reverse lookup for deduplicating repeat references.

Lifecycle

CreateWriter / CreateReader

Both take from a free list before allocating. capacity defaults to ALLOC_SIZE (4096) and is ignored when a pooled writer is available.

FreeWriter / FreeReader

Resets the object and returns it to the pool. FreeWriter zeroes the cursor and clears both instance tables. A writer whose capacity exceeded MAX_REUSE_SIZE (64 KB) is discarded rather than pooled.
Using a writer or reader after freeing it corrupts whichever send is holding it next. If you write a custom type, never call these, because the writer belongs to the Bridge.

Finalize

Copies the used cursor bytes into an exactly-sized buffer and clones the instance array. Returns both, ready to pass to a remote.
The copy is necessary because the writer’s backing buffer is about to be reused. This is the only allocation steady-state Flux performs, and it is unavoidable, since the remote needs a buffer it owns.

Growth

Capacity doubles until the pending write fits. Every public Write* checks cursor + size > len first, so overflow is impossible.

Primitives

Each pair reads and writes a fixed width, advancing the cursor by that many bytes.
These delegate straight to buffer.write*, which does not validate. Out-of-range values silently truncate to the low bits, and non-integers truncate toward zero.

Varints

LEB128-style: seven payload bits per byte, high bit set to continue. Unsigned only.

Strings and buffers

Both write a varint length followed by raw bytes. WriteString uses buffer.writestring, so binary content is safe and lengths are in bytes rather than characters.

Roblox datatypes

WriteUDim, WriteUDim2, WriteRegion3, WriteBrickColor, WriteTweenInfo and WriteBuffer are reachable only through WriteAny, because the Types namespace has no constructor for them. Call them directly from a custom type if you need them under a schema.
Three of these are lossy in ways worth knowing.ReadCFrame rebuilds with CFrame.fromAxisAngle(axis, angle) + pos, which is not bit-exact and is degenerate for zero rotation. ReadColor3 quantises to 8 bits per channel with a downward floor bias. ReadRegion3 rebuilds axis-aligned from position plus or minus half the size, discarding the original CFrame’s rotation entirely.See Serialization.

Instance handling

WriteInstance appends to w.insts, or reuses the existing w.instIndex[v], and writes a varint index. Nothing about the instance enters the buffer.
ReadInstance returns r.insts[idx], which is nil whenever the receiver cannot see the instance. Note the return type is Instance? while Types.Instance() is typed Type<Instance>, so the non-optional annotation is optimistic. Always nil-check.

Enum caching

WriteEnum memoises tostring(item) in EnumCache, and ReadEnum memoises the reverse in StringToEnumCache, resolving cache misses by splitting on . and indexing Enum. An unresolvable name returns nil rather than erroring.

Packet header

Packs a two-bit type into the high bits of one byte and the argument count into the low six. A count of 63 or more writes 63 as an escape and follows with a varint. See Packets.

Dynamic encoder

Writes a one-byte type tag then the value, dispatching on typeof(v). Used for every unschematised event argument and for all invoke traffic.
Integers are written at the narrowest width that fits: u8, u16 or u32 when non-negative, i16 or i32 when negative, and f64 otherwise. WriteI8 is never selected, so small negatives cost two bytes.
A single pairs walk decides array against dictionary, breaking on the first key that is not an integer in 1..n. Arrays get TYPE_ARRAY plus a varint count; dictionaries get TYPE_TABLE and alternating key and value pairs terminated by a nil key.
Recursion has no visited-set and no depth limit, so a table referencing itself recurses until the Luau stack overflows.
An unrecognised typeof logs warn("Unsupported type: " .. t) on the sender and writes TYPE_NIL. The receiver gets nil with no indication anything went wrong, and the argument count still matches.
See the full tag table.

Pack / Unpack

Writes a varint count followed by that many WriteAny values. Unpack returns them as an array.
Public but unused by Flux itself, because the Server and Client modules write their argument counts through the packet header instead. Available if you are building your own framing on top of Buffers.

Constants

4 KB comfortably holds a typical frame of traffic, which is why steady-state Flux performs no buffer allocation. Writers cycle between the pool and the bridge, and only Finalize allocates.

Next steps

Custom types

Using these primitives in a schema entry.

Type tags

Every tag and its byte cost.