msgpackr vs protobufjs vs avsc: Binary Serialization in Node.js (2026)
TL;DR
msgpackr is the fastest binary serialization library for Node.js — a MessagePack implementation that's 2-5x faster than JSON.stringify and produces smaller payloads, with no schema required. protobufjs implements Google's Protocol Buffers — schema-defined, strongly typed, excellent for gRPC and cross-language APIs. avsc implements Apache Avro — schema-based like Protobuf but with better Kafka integration and schema registry support. For internal Node.js IPC or WebSocket payloads: msgpackr. For gRPC services or cross-language APIs with strict contracts: protobufjs. For Kafka/streaming pipelines: avsc.
Key Takeaways
- msgpackr: ~6M weekly downloads — fastest, no schema, drop-in JSON replacement
- protobufjs: ~18M weekly downloads — Protocol Buffers, gRPC,
.protoschemas - avsc: ~2M weekly downloads — Apache Avro, Kafka, schema registry
- All three beat JSON on both speed and payload size
- Schema-based formats (Protobuf, Avro) provide type safety and schema evolution — at the cost of setup
- For most web APIs, JSON is fine — reach for binary when you have measurable performance needs
Why Binary Serialization?
JSON: {"name":"react","downloads":45000000,"score":95} → 52 bytes
msgpack: <binary> → ~35 bytes (−33%)
protobuf: <binary> → ~18 bytes (−65%)
avro: <binary> → ~15 bytes (−71%)
Performance (serialize + deserialize 100K objects):
JSON.stringify/parse: ~800ms
msgpackr encode/decode: ~180ms (4.4x faster)
protobufjs encode/decode: ~250ms (3.2x faster)
avsc encode/decode: ~150ms (5.3x faster)
msgpackr
msgpackr — the fastest MessagePack implementation for Node.js:
Basic usage (drop-in JSON replacement)
import { pack, unpack } from "msgpackr"
// Pack (serialize):
const data = {
name: "react",
downloads: 45_000_000,
score: 95,
tags: ["ui", "frontend"],
publishedAt: new Date("2013-05-29"),
}
const binary = pack(data)
// Returns Buffer/Uint8Array
// Unpack (deserialize):
const restored = unpack(binary)
// { name: "react", downloads: 45000000, score: 95, tags: [...], publishedAt: Date }
// Dates are automatically preserved — JSON.stringify loses them
Structured (schema-aware, fastest mode)
import { Packr } from "msgpackr"
// Structured mode: define expected keys upfront → smaller output, faster
const packr = new Packr({ structuredClone: true, useRecords: true })
// Record definition — msgpackr encodes keys as integers
const PackageRecord = packr.addExtension({
Class: Object,
type: 1,
// Keys shared between packr instances must be consistent
})
// With structure definition (fastest):
const structured = new Packr({
structures: [
// Define the keys that will appear — stored by index not name
["name", "downloads", "healthScore", "tags", "publishedAt"],
],
})
const packed = structured.pack({ name: "react", downloads: 45_000_000, healthScore: 95, tags: [], publishedAt: new Date() })
// Significantly smaller — key names replaced with 1-byte index
const unpacked = structured.unpack(packed)
WebSocket binary messages
import { WebSocketServer } from "ws"
import { pack, unpack } from "msgpackr"
const wss = new WebSocketServer({ port: 8080 })
wss.on("connection", (ws) => {
// Send binary msgpack instead of JSON strings:
function send(data: unknown) {
ws.send(pack(data)) // ~35% smaller than JSON
}
ws.on("message", (message) => {
const data = unpack(message as Buffer)
// data is fully typed — use as needed
})
// Push real-time package updates:
setInterval(() => {
send({
type: "health_update",
packages: [
{ name: "react", score: 95, trend: +2 },
{ name: "vue", score: 91, trend: -1 },
],
timestamp: Date.now(),
})
}, 5000)
})
Node.js IPC with msgpackr
import { fork } from "child_process"
import { pack, unpack } from "msgpackr"
// Parent process:
const worker = fork("worker.js")
// Send msgpack binary via IPC:
worker.send(pack({ command: "process", data: largeDataset }))
worker.on("message", (msg) => {
const result = unpack(msg as Buffer)
console.log(result)
})
// worker.js:
process.on("message", (msg) => {
const { command, data } = unpack(msg as Buffer)
// Process and respond:
process.send!(pack({ status: "done", result: data.length }))
})
protobufjs
protobufjs — Google Protocol Buffers for Node.js:
Define schema (.proto file)
// package.proto
syntax = "proto3";
package pkgpulse;
message Package {
string name = 1;
uint64 weekly_downloads = 2;
float health_score = 3;
repeated string tags = 4;
string version = 5;
bool is_deprecated = 6;
}
message PackageList {
repeated Package packages = 1;
uint32 total = 2;
string cursor = 3;
}
Load and use
import protobuf from "protobufjs"
// Load .proto file:
const root = await protobuf.load("package.proto")
const PackageMessage = root.lookupType("pkgpulse.Package")
// Verify payload before encoding:
const payload = {
name: "react",
weeklyDownloads: 45_000_000,
healthScore: 95.0,
tags: ["ui", "frontend"],
version: "18.3.1",
isDeprecated: false,
}
const errMsg = PackageMessage.verify(payload)
if (errMsg) throw new Error(errMsg)
// Encode:
const message = PackageMessage.create(payload)
const buffer = PackageMessage.encode(message).finish()
// Returns Uint8Array ~18 bytes for this payload (vs 120 bytes JSON)
// Decode:
const decoded = PackageMessage.decode(buffer)
const obj = PackageMessage.toObject(decoded, {
longs: Number, // Convert Long → number
defaults: true, // Include fields with default values
})
TypeScript-first with ts-proto
// ts-proto generates TypeScript types from .proto files (preferred over protobufjs types):
// npm install ts-proto
// protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=. package.proto
// Generated types (from ts-proto):
interface Package {
name: string
weeklyDownloads: number
healthScore: number
tags: string[]
version: string
isDeprecated: boolean
}
// Encode/decode with generated code:
import { Package } from "./package"
const encoded = Package.encode(Package.fromPartial({
name: "react",
weeklyDownloads: 45_000_000,
healthScore: 95,
})).finish()
const decoded: Package = Package.decode(encoded)
gRPC with protobufjs
import * as grpc from "@grpc/grpc-js"
import * as protoLoader from "@grpc/proto-loader"
const packageDefinition = protoLoader.loadSync("pkgpulse.proto", {
keepCase: true,
longs: String,
defaults: true,
oneofs: true,
})
const pkgpulse = grpc.loadPackageDefinition(packageDefinition).pkgpulse as any
// Create gRPC client:
const client = new pkgpulse.PackageService(
"api.pkgpulse.com:443",
grpc.credentials.createSsl()
)
// Call gRPC method (Protocol Buffers handles serialization):
client.getPackage({ name: "react" }, (err: Error, response: any) => {
console.log(response) // Deserialized Package message
})
avsc
avsc — Apache Avro for Node.js (Kafka ecosystem):
Basic usage
import avro from "avsc"
// Define Avro schema:
const PackageSchema = avro.Type.forSchema({
type: "record",
name: "Package",
namespace: "com.pkgpulse",
fields: [
{ name: "name", type: "string" },
{ name: "weeklyDownloads", type: "long" },
{ name: "healthScore", type: "float" },
{ name: "tags", type: { type: "array", items: "string" } },
{ name: "publishedAt", type: { type: "long", logicalType: "timestamp-millis" } },
{ name: "deprecated", type: "boolean", default: false },
],
})
// Encode:
const buffer = PackageSchema.toBuffer({
name: "react",
weeklyDownloads: 45_000_000,
healthScore: 95.0,
tags: ["ui", "frontend"],
publishedAt: Date.now(),
deprecated: false,
})
// Decode:
const pkg = PackageSchema.fromBuffer(buffer)
console.log(pkg.name) // "react"
Kafka integration (schema registry pattern)
import { Kafka } from "kafkajs"
import avro from "avsc"
const schema = avro.Type.forSchema({
type: "record",
name: "PackageEvent",
fields: [
{ name: "eventType", type: { type: "enum", name: "EventType", symbols: ["CREATED", "UPDATED", "DEPRECATED"] } },
{ name: "packageName", type: "string" },
{ name: "timestamp", type: { type: "long", logicalType: "timestamp-millis" } },
{ name: "metadata", type: { type: "map", values: "string" } },
],
})
const kafka = new Kafka({ brokers: ["localhost:9092"] })
const producer = kafka.producer()
await producer.connect()
// Produce Avro-encoded messages:
await producer.send({
topic: "package-events",
messages: [{
key: "react",
value: schema.toBuffer({
eventType: "UPDATED",
packageName: "react",
timestamp: Date.now(),
metadata: { version: "18.3.1", downloads: "45000000" },
}),
}],
})
// Consume and decode:
const consumer = kafka.consumer({ groupId: "pkgpulse-processor" })
await consumer.connect()
await consumer.subscribe({ topic: "package-events" })
await consumer.run({
eachMessage: async ({ message }) => {
const event = schema.fromBuffer(message.value!)
console.log(`${event.eventType}: ${event.packageName}`)
},
})
Schema evolution
import avro from "avsc"
// V1 schema:
const v1 = avro.Type.forSchema({
type: "record",
name: "Package",
fields: [
{ name: "name", type: "string" },
{ name: "downloads", type: "long" },
],
})
// V2 schema — added field with default (backward compatible):
const v2 = avro.Type.forSchema({
type: "record",
name: "Package",
fields: [
{ name: "name", type: "string" },
{ name: "downloads", type: "long" },
{ name: "healthScore", type: "float", default: 0.0 }, // New field with default!
],
})
// Read V1 data with V2 reader (schema evolution):
const resolver = v2.createResolver(v1) // V2 reads V1 data
const v1Data = v1.toBuffer({ name: "react", downloads: 45_000_000 })
const v2Object = v2.fromBuffer(v1Data, resolver)
// { name: "react", downloads: 45000000, healthScore: 0 } ← default applied
Feature Comparison
| Feature | msgpackr | protobufjs | avsc |
|---|---|---|---|
| Schema required | ❌ | ✅ .proto | ✅ JSON schema |
| Performance | ⚡ Fastest | Fast | Fastest (compiled) |
| Payload size | Small | Smallest | Smallest |
| Schema evolution | ❌ | ✅ | ✅ Excellent |
| TypeScript | ✅ | ✅ (ts-proto) | ✅ |
| gRPC | ❌ | ✅ | ❌ |
| Kafka | ❌ | ❌ | ✅ Native |
| Cross-language | ✅ msgpack spec | ✅ proto spec | ✅ avro spec |
| No-schema mode | ✅ | ❌ | ❌ |
| Self-describing | ✅ | ❌ (schema needed) | ✅ (schema in header) |
When to Use Each
Choose msgpackr if:
- Replacing JSON for internal Node.js communication (IPC, WebSocket, Redis values)
- You don't want to define schemas upfront — just faster JSON
- Real-time applications where serialization speed matters
- You want Date, Map, Set, and typed arrays to survive serialization (JSON loses them)
Choose protobufjs if:
- Building gRPC services (Protocol Buffers is the gRPC wire format)
- Cross-language APIs where Python, Go, Java, or other clients consume the same data
- You want strict schema contracts and backward/forward compatibility
- Teams large enough to benefit from enforced data contracts
Choose avsc if:
- Working with Kafka — Avro is the standard format for the Confluent ecosystem
- You need schema evolution with a schema registry
- Data pipelines, event streaming, and data lake use cases
- Hadoop or Spark integration (Avro is native there)
Stick with JSON if:
- Web APIs consumed by browsers — JSON is native
- Performance is not a bottleneck (most APIs are DB-bound, not serialization-bound)
- Debugging matters — binary formats are opaque without tooling
- Team doesn't have binary serialization experience
Methodology
Download data from npm registry (weekly average, February 2026). Performance benchmarks are approximate — actual numbers vary by payload structure. Feature comparison based on msgpackr v3.x, protobufjs v7.x, and avsc v5.x.
Compare serialization and data processing packages on PkgPulse →