npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nitrobuf

v0.1.1

Published

Compact binary serialization with inline TypeScript schemas — smaller packets for real-time games.

Downloads

44

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.ts declarations

Install

npm install nitrobuf

Quick 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/f64 are 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 schemas

Performance

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 throughput

See 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 schema
  • nb.array(element) — Array of a single type
  • nb.map(key, value) — Map with typed keys and values
  • nb.optional(inner) — Value or undefined
  • nb.nullable(inner) — Value or null
  • nb.enumOf(variants) — String enum
  • nb.union(variants) — Tagged union
  • schema.encode(value) — Encode to Uint8Array
  • schema.decode(buf) — Decode from Uint8Array
  • schema.id(n) — Assign field ID for tagged structs (required when { tagged: true })

Socket.IO

  • createParser(options) — Create a Socket.IO-compatible parser
    • options.events — Map of event names to schemas
    • options.strict — If true, throw on unregistered events (default: false)

Utilities

  • configure({ mode }) — Set codec compilation mode
  • getCompilerMode() — Effective compiler ("jit" | "interpreter") for uncompiled schemas
  • dynamicEncodeToBytes(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