> ## Documentation Index
> Fetch the complete documentation index at: https://flux-docs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Serialization

> How Flux turns Luau values into bytes, with and without a schema.

Flux has two encoders. Which one runs depends on whether the event has a schema.

<CardGroup cols={2}>
  <Card title="Dynamic" icon="wand-magic-sparkles" horizontal>
    `WriteAny` and `ReadAny`. One tag byte per value describing its type, then the value.
    Handles anything, and agrees on nothing in advance.
  </Card>

  <Card title="Schematised" icon="ruler" horizontal>
    `schema[i].Write` and `.Read`. No tags, because both sides already know the layout.
    Fixed shape, smaller payload.
  </Card>
</CardGroup>

<Note>
  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](/guides/invocations#invocations-ignore-schemas).
</Note>

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

```mermaid theme={null}
graph TD
    N["number"] --> Int{"v % 1 == 0?"}
    Int -->|no| F64["f64, 8 bytes"]
    Int -->|yes| Sign{"v >= 0?"}
    Sign -->|yes| P1{"<= 255?"}
    P1 -->|yes| U8["u8, 1 byte"]
    P1 -->|no| P2{"<= 65535?"}
    P2 -->|yes| U16["u16, 2 bytes"]
    P2 -->|no| P3{"<= 4294967295?"}
    P3 -->|yes| U32["u32, 4 bytes"]
    P3 -->|no| F64
    Sign -->|no| N1{">= -32768?"}
    N1 -->|yes| I16["i16, 2 bytes"]
    N1 -->|no| N2{">= -2147483648?"}
    N2 -->|yes| I32["i32, 4 bytes"]
    N2 -->|no| F64
```

| Value      | Encoded as | Payload bytes, plus 1 tag |
| ---------- | ---------- | ------------------------- |
| `0`, `200` | `u8`       | 1                         |
| `1000`     | `u16`      | 2                         |
| `100000`   | `u32`      | 4                         |
| `-500`     | `i16`      | 2                         |
| `-100000`  | `i32`      | 4                         |
| `1.5`      | `f64`      | 8                         |
| `2^53`     | `f64`      | 8                         |

<Warning>
  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.
</Warning>

## Varints

Lengths and indices use a LEB128-style varint: seven bits of payload per byte, high bit
set to continue.

| Value range          | Bytes |
| -------------------- | ----- |
| `0` to `127`         | 1     |
| `128` to `16383`     | 2     |
| `16384` to `2097151` | 3     |

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.

<Info>
  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.
</Info>

## Tables

The dynamic encoder classifies a table before writing it.

<Tabs>
  <Tab title="Arrays">
    A table whose keys are exactly `1..n` is written as a compact array: the tag, a varint
    count, then each element.

    ```
    TYPE_ARRAY, varint(n), value1, value2, ... valueN
    ```

    ```luau theme={null}
    Scores:FireAll({ 10, 20, 30 })
    ```
  </Tab>

  <Tab title="Dictionaries">
    Anything else is written as key and value pairs terminated by a `nil` key.

    ```
    TYPE_TABLE, key1, value1, key2, value2, ..., TYPE_NIL
    ```

    ```luau theme={null}
    Stats:FireAll({ Health = 100, Team = "Red" })
    ```

    Keys are themselves dynamically encoded, so any serialisable type works as a key.
  </Tab>
</Tabs>

The classification walks the table once, breaking early on the first key that is not a
positive integer within `1..n`.

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

  ```luau theme={null}
  local t = {}
  t.self = t
  MyEvent:Fire(t) -- STACK OVERFLOW
  ```

  Break cycles before sending, or send an id and let the receiver rebuild the structure.
</Warning>

<Note>
  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.
</Note>

## Instances

Instances are not serialised into the buffer at all.

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Ride alongside the buffer">
    `Finalize` returns `(buffer, instances)` and the remote is fired with both. Roblox
    translates the instance references natively.
  </Step>
</Steps>

<Info>
  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.
</Info>

## Lossy conversions

Some datatypes are deliberately encoded narrowly. These are not round-trip exact.

<AccordionGroup>
  <Accordion title="CFrame: position plus axis-angle, 7 f32 values, 28 bytes" icon="cube">
    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.
  </Accordion>

  <Accordion title="Color3: three u8 values, 3 bytes" icon="palette">
    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`.
  </Accordion>

  <Accordion title="Region3: loses rotation" icon="square-full">
    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.
  </Accordion>

  <Accordion title="EnumItem: full string name" icon="list">
    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.
  </Accordion>

  <Accordion title="TweenInfo: three f64 values, two enums and a bool" icon="timeline">
    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.
  </Accordion>
</AccordionGroup>

## Buffer growth and pooling

<ResponseField name="Initial capacity" type="4096 bytes">
  Every writer starts at 4 KB.
</ResponseField>

<ResponseField name="Growth" type="doubling">
  When a write would overflow, capacity doubles until it fits, and the used bytes are
  copied into the new buffer.
</ResponseField>

<ResponseField name="Finalize" type="exact copy">
  On flush, only `cursor` bytes are copied into a right-sized buffer, so an oversized
  writer never sends padding.
</ResponseField>

<ResponseField name="Pool" type="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.
</ResponseField>

<Info>
  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.
</Info>

## Next steps

<CardGroup cols={2}>
  <Card title="Type tags" icon="hashtag" href="/concepts/wire-format/type-tags">
    The full tag table and per-type byte costs.
  </Card>

  <Card title="Supported types" icon="table-list" href="/reference/supported-types">
    Everything Flux can and cannot send.
  </Card>
</CardGroup>
