nitrobuf
v0.1.1
Published
Compact binary serialization with inline TypeScript schemas — smaller packets for real-time games.
Downloads
44
Maintainers
Readme
nitrobuf
Compact binary serialization with inline TypeScript schemas. Packets are typically half the size of JSON — and ~3× smaller on numeric game ticks — with no .proto files and no codegen step. Define schemas in TypeScript, get type-safe encode/decode, and drop in as a Socket.IO parser for realtime games.
Features
- Smaller than JSON — positional fields (no key names on the wire), fixed-width floats, varints
- Realtime / game ticks —
{x,y,z}moves and entity snapshots stay tiny at high frequency - Inline schemas — define data shapes in TypeScript, not separate files
- Full type inference —
Infer<typeof schema>extracts the TS type automatically - Fast — JIT-compiled codecs via
new Function, with safe closure-based fallback for CSP environments - Schema evolution — tagged structs support forward/backward compatibility
- Socket.IO parser — drop-in replacement for socket.io-parser with per-event schemas
- Zero runtime dependencies — the core library has no npm dependencies
- Dual format — ships ESM + CJS with full
.d.tsdeclarations
Install
npm install nitrobufQuick Start
import { nb, type Infer } from "nitrobuf";
// Define a schema
const User = nb.struct({
id: nb.uint,
name: nb.string,
email: nb.optional(nb.string),
role: nb.enumOf(["admin", "user", "guest"]),
tags: nb.array(nb.string),
createdAt: nb.date,
});
// TypeScript type is automatically inferred
type User = Infer<typeof User>;
// { id: number; name: string; email?: string; role: "admin" | "user" | "guest"; tags: string[]; createdAt: Date }
// Encode to binary
const bytes: Uint8Array = User.encode({
id: 42,
name: "Alice",
email: "[email protected]",
role: "admin",
tags: ["verified"],
createdAt: new Date(),
});
// Decode back
const user = User.decode(bytes);Why smaller than JSON
JSON repeats field names on every object and writes numbers as decimal text. nitrobuf positional structs send values only:
- No field names —
{x,y,z}is 12 bytes (3 × f32), not{"x":…,"y":…,"z":…} - Fixed floats —
f32/f64are always 4 or 8 bytes; JSON length grows with precision - Varints — small integers (
id,tick,hp) take 1–2 bytes instead of multi-digit strings - Optional bitmask — presence is packed bits, not omitted keys or
"field":null
This is the difference that matters at 20–60 Hz per client: less bandwidth, fewer bytes to copy, less GC from stringifying numbers.
Measured payload size vs JSON.stringify (UTF-8 bytes), same values:
| Payload | nitrobuf | JSON | vs JSON |
| ----------------------------- | -------- | ------ | ---------------- |
| Player move {x,y,z} | 12 B | 33 B | 2.8× smaller |
| Player state (pos + vel + hp) | 27 B | 83 B | 3.1× smaller |
| World snapshot (64 entities) | 1731 B | 5425 B | 3.1× smaller |
| Chat message | 21 B | 42 B | 2.0× smaller |
| User object (mixed fields) | 71 B | 140 B | 2.0× smaller |
String-heavy payloads save less (UTF-8 is UTF-8). Numeric ticks and snapshots save the most. Full table: pnpm run bench:size. Details in docs/performance.md.
Type Reference
| Builder | TypeScript Type | Wire Format |
| ------------------ | --------------------------- | -------------------------------- |
| nb.u8 | number | 1 byte unsigned |
| nb.u16 | number | 2 bytes LE unsigned |
| nb.u32 | number | 4 bytes LE unsigned |
| nb.u64 | bigint | 8 bytes LE unsigned |
| nb.i8 | number | 1 byte signed |
| nb.i16 | number | 2 bytes LE signed |
| nb.i32 | number | 4 bytes LE signed |
| nb.i64 | bigint | 8 bytes LE signed |
| nb.f32 | number | 4 bytes IEEE 754 |
| nb.f64 | number | 8 bytes IEEE 754 |
| nb.uint | number | LEB128 varint |
| nb.int | number | Zigzag varint |
| nb.bool | boolean | 1 byte |
| nb.string | string | Varint length + UTF-8 |
| nb.bytes | Uint8Array | Varint length + raw |
| nb.date | Date | Zigzag varint (ms) |
| nb.bigint | bigint | Sign + varint len + LE magnitude |
| nb.any | unknown | Dynamic (msgpack-like) |
| nb.array(T) | T[] | Varint length + elements |
| nb.map(K, V) | Map<K, V> | Varint length + key-value pairs |
| nb.optional(T) | T \| undefined | Presence byte + value |
| nb.nullable(T) | T \| null | Presence byte + value |
| nb.enumOf([...]) | Union of string literals | Varint index |
| nb.union({...}) | { tag: string; value: T } | Varint discriminant + value |
| nb.struct({...}) | Object | Positional or tagged |
Positional vs Tagged Structs
Positional (default) — smallest and fastest
Fields are encoded in declaration order. Optional fields use a compact bitmask. Both sides must use the same schema version. Use this for high-frequency game events.
const Message = nb.struct({
text: nb.string,
ts: nb.uint,
});Tagged — schema evolution
Each field gets a numeric ID. Unknown fields are skipped on decode, missing fields become undefined. Supports adding/removing optional fields across versions.
const MessageV1 = nb.struct(
{
text: nb.string.id(1),
ts: nb.uint.id(2),
},
{ tagged: true },
);
// V2 adds a field — V1 decoders will silently skip it
const MessageV2 = nb.struct(
{
text: nb.string.id(1),
ts: nb.uint.id(2),
sender: nb.optional(nb.string).id(3),
},
{ tagged: true },
);Socket.IO Integration
nitrobuf ships a drop-in custom parser for Socket.IO. Register positional schemas for high-frequency events (player:move, world snapshots) so every tick stays compact; unregistered events fall back to a dynamic (schemaless) codec, which is larger than a schema.
import { Server } from "socket.io";
import { io } from "socket.io-client";
import { createParser } from "nitrobuf/socket.io";
import { nb } from "nitrobuf";
const parser = createParser({
events: {
"chat:message": nb.struct({ text: nb.string, ts: nb.uint }),
"player:move": nb.struct({ x: nb.f32, y: nb.f32, z: nb.f32 }),
},
});
// Server
const server = new Server(httpServer, { parser });
// Client
const socket = io("http://localhost:3000", { parser });
// Use normally — schema encoding is automatic for registered events
socket.emit("player:move", { x: 12.5, y: 1.0, z: -4.25 });
socket.emit("chat:message", { text: "Hello!", ts: Date.now() });
// Unregistered events work too (dynamic codec fallback)
socket.emit("other-event", { any: "data" });Important: The same parser must be used on both server and client.
Limitations
- ACK packets always use the dynamic codec (the encoder cannot determine which event an ACK belongs to)
- The parser encodes all packets as binary
Uint8Array— HTTP long-polling transports will base64-encode them, adding ~33% overhead
Configuration
import { configure, getCompilerMode } from "nitrobuf";
// Force interpreter mode (safe for CSP environments)
configure({ mode: "interpreter" });
// Force JIT mode
configure({ mode: "jit" });
// Auto-detect (default)
configure({ mode: "auto" });
getCompilerMode(); // "jit" | "interpreter" — effective compiler for uncompiled schemasPerformance
nitrobuf wins on packet size (always, for these shapes) and on throughput for nested/numeric data. V8's JSON is still faster to encode/decode trivial structs.
Encoded size vs JSON
Same payloads as pnpm run bench:size (UTF-8 byte length of JSON.stringify):
| Payload | nitrobuf | JSON | Ratio |
| --------------------- | -------- | ------ | ---------------- |
| Player move {x,y,z} | 12 B | 33 B | 2.8× smaller |
| Snapshot 64 entities | 1731 B | 5425 B | 3.1× smaller |
| Nested 100 users | 3392 B | 6396 B | 1.9× smaller |
| Simple user object | 71 B | 140 B | 2.0× smaller |
Throughput (ops/s)
Benchmarks on Node.js v22 (Apple Silicon):
| Scenario | nitrobuf | JSON | Ratio | | ------------------------- | ---------- | ---------- | --------------- | | Encode simple struct | 2.1M ops/s | 2.8M ops/s | 0.75x | | Decode simple struct | 2.1M ops/s | 2.7M ops/s | 0.77x | | Encode 100 nested objects | 143K ops/s | 39K ops/s | 3.7x faster | | Decode 100 nested objects | 111K ops/s | 32K ops/s | 3.5x faster |
pnpm run bench:size # packet size vs JSON
pnpm run bench # encode/decode throughputSee docs/performance.md for analysis.
Comparison
| Feature | nitrobuf | protobuf | msgpack | JSON | | -------------------- | ----------- | ----------- | --------------- | -------- | | Schema in code | Yes | No (.proto) | No (schemaless) | No | | Type inference | Yes | Via codegen | No | Manual | | Binary format | Yes | Yes | Yes | No | | Compact game ticks | Yes | Yes | Partial | No | | Schema evolution | Tagged mode | Yes | N/A | N/A | | Zero dependencies | Yes | No | Varies | Built-in | | Socket.IO parser | Yes | No | Yes | Default | | Code generation step | No (JIT) | Yes | No | No |
API
Core
nb.struct(fields, options?)— Create a struct schemanb.array(element)— Array of a single typenb.map(key, value)— Map with typed keys and valuesnb.optional(inner)— Value orundefinednb.nullable(inner)— Value ornullnb.enumOf(variants)— String enumnb.union(variants)— Tagged unionschema.encode(value)— Encode toUint8Arrayschema.decode(buf)— Decode fromUint8Arrayschema.id(n)— Assign field ID for tagged structs (required when{ tagged: true })
Socket.IO
createParser(options)— Create a Socket.IO-compatible parseroptions.events— Map of event names to schemasoptions.strict— If true, throw on unregistered events (default: false)
Utilities
configure({ mode })— Set codec compilation modegetCompilerMode()— Effective compiler ("jit"|"interpreter") for uncompiled schemasdynamicEncodeToBytes(value)— Encode any JS value (schemaless)dynamicDecodeFromBytes(buf)— Decode from schemaless format
Requirements
- Node.js ^20.19.0 || >= 22.12.0
- Works in browsers (ESM)
- TypeScript >= 5.0 for type inference
License
MIT
