norns-tron

Performance guide

Pick the right mode, compile schemas once, declare enums, do not encode small payloads, and reach for the columnar tape when data is numeric.

The ratios below are measured (Node 22, interleaved pairs against native JSON, median of 15 rounds). The ratio is TRON / JSON; under 1.00 means TRON is faster. Absolute milliseconds depend on the machine; the ratios are the signal.

#1. Choose the right mode

Situation Use decode encode round-trip tokens saved
Internal API, both ends are your code defineSchema / tronSchemaFromValibot 0.40–0.87× 0.66–0.93× 0.57–0.91× 11–61%
LLM prompt or third-party consumer encode / decode 0.45–0.77× 0.97–1.77× 0.76–1.22× 26–61%
Numeric or columnar data decodeColumnar ~0.30× 35%
Payload under ~1 KB plain JSON 0%

The gap between schema mode and self-describing mode is the largest optimisation available, bigger than any fine-tuning. Schema mode wins on every axis at every size.

#2. Preload once, at application start

The number-one mistake is compiling the schema per request. defineSchema does real work: it resolves field order, enum tables and the path, and compiles the row constructor with new Function. Do it once at module scope, as schema mode shows. Measured effect on a 25-row envelope: decode 1.21× → 0.63×, encode 2.49× → 0.90×.

#3. Declare enums for low-cardinality columns

Any string or boolean column with few distinct values (status, role, region, flags) becomes an integer on the wire. Three effects at once: fewer tokens (events-10k: 38% → 42%), faster decode (no string allocation for that column), and WASM eligibility: a dictionary column is an integer on the wire, so a table with strings becomes eligible for the WASM scanner. Values must be strings or booleans.

#4. Do not encode small payloads

encode() already returns plain JSON under 1 KB and decode() reads it transparently. In self-describing mode:

rows JSON decode round-trip
5 0.5 KB 4.80× 5.16×
25 2.1 KB 1.21× 1.70×
100 8.4 KB 0.78× 1.16×
1000 86 KB 0.68× 1.01×
10000 881 KB 0.67× 0.94×

With a preloaded schema the threshold disappears (see §1).

#5. Numeric data: take the columnar tape

The fastest path in the library; it never builds JavaScript objects.

const wire = encodeColumnar(rows);               // server
const col = decodeColumnar(wire, /* copy */ true); // client
if (col) {
  const v = col.tape[r * col.cols + c];           // row-major
} else {
  const obj = decode(wire);                        // not eligible, normal path
}

About 0.30× of JSON.parse. copy: true detaches the tape from WASM memory; without it the next decode invalidates it.

#6. table: 'nested' trades tokens for speed

The default (table: true) tabulates only flat rows: a pure win, faster and fewer tokens. 'nested' also accepts rows with nested objects: faster still, but the inner shapes lose their own compression. Measured on nested-500: round-trip 2.47× → 0.89×, tokens saved 26% → 11%. Pick by what you pay for, latency or tokens.

#7. Runtime details

  • new Function is used by the trampoline and row constructors. Under a strict CSP without unsafe-eval the library falls back to the scanner (parseFast), about 1.1–1.5×. Correct, just slower.
  • WASM is optional. Node and Bun load the embedded binary; in a browser you may also supply it with setWasmBinary(...). Without it nothing breaks; the dispatcher just skips the WASM path.
  • Send Content-Type: application/tron (route() does). Never application/json.
  • HTTP compression still applies. TRON reduces bytes before gzip or brotli; the gains compound, less than linearly.
  • Do not re-encode what has not changed; cacheable responses encode once and keep only the decode win.

#8. Checklist

  • defineSchema / tronSchemaFromValibot called once at startup, not per request
  • enums declared for every categorical and boolean column
  • payloads under ~1 KB left as JSON (the default threshold does this)
  • encodeColumnar + decodeColumnar used together where data is numeric
  • content type set to application/tron
  • in the browser, setWasmBinary called or the fallback consciously accepted
  • measured with your own data before relying on these figures

#Methodology

A naive benchmark gave contradictory results (two identical code paths differing by 43%) because running several decoders in one process makes V8's inline caches polymorphic. The numbers here use one child process per data set, interleaved A/B/A/B pairs with alternating order, and the median of 15 rounds. Correctness: 220,118 adversarial and fuzz checks across the suite, 22 dedicated schema-mode tests and 1,500 fuzz round-trips, zero failures.