Skip to main content

msgpackr vs protobufjs vs avsc: Binary Serialization in Node.js (2026)

·PkgPulse Team

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, .proto schemas
  • 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

Featuremsgpackrprotobufjsavsc
Schema required.proto✅ JSON schema
Performance⚡ FastestFastFastest (compiled)
Payload sizeSmallSmallestSmallest
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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.