Last Black Friday, we pushed a 2.1KB JSON object to our edge cache in 285 PoPs and watched p95 TTFB creep from 38ms to 92ms under load. It wasn't the network — it was deserialization. When you're handling 1.2M requests per second per region, that JSON.parse() tax and the extra bytes on the wire add up faster than you'd think. I've spent the last decade tuning what we store at the edge, and the serialization format you pick will make or break your tail latency.
Why JSON's Text Tax Hurts More at the Edge
Everyone starts with JSON. It's human-readable, every language understands it, and you don't need a build step. But at the edge, readability doesn't matter — bytes and CPU cycles do. JSON carries field names as strings in every single cached object, it encodes numbers as text, and it has no native binary type. I learned this the hard way in 2019 when we cached user personalization payloads as JSON in our edge KV. Each object was around 4.3KB, and with 50 million keys, we were burning through SSD and memory budget just storing quotation marks and colons.
Binary formats solve this by stripping that overhead. MessagePack, CBOR, and Protobuf all encode data in a compact binary representation, but they do it in very different ways that affect how they behave in a Cache Coherence Protocols at the CDN Edge: Keeping Distributed Caches in Sync pipeline. MessagePack and CBOR are schemaless and self-describing — they still store type information, but far more efficiently than JSON. Protobuf is schema-driven and drops field names entirely, replacing them with tiny integer tags. That distinction determines whether you can deploy a new field without invalidating your entire cache fleet, and whether your edge workers spend more time decoding or more time fetching.
The other hidden cost is parsing. JSON parsing at the edge isn't just slow — it's unpredictable. V8's JSON.parse is fast for small objects but suffers from GC pressure when you're parsing thousands of objects per second on a worker with 128MB of memory. Binary decoders, especially zero-copy ones, avoid creating intermediate strings. As the HTTP Working Group Specifications remind us with structured fields and cache header evolution, what you put in the cache should be optimized for machines, not humans. Here's the kind of cache object we were storing — and why its Protobuf definition is a fraction of the JSON size:
// edge-cache.proto - cached personalization object
syntax = "proto3";
package edge.cache;
message CachedResponse {
uint32 version = 1;
int64 ttl_epoch_ms = 2;
bytes etag = 3; // raw bytes, not base64 string
repeated string vary_keys = 4;
map<string, bytes> fragments = 5; // binary fragments, no JSON escaping
CacheControl control = 6;
}
message CacheControl {
uint32 max_age = 1;
bool stale_while_revalidate = 2;
uint32 swr_window_s = 3;
}
// JSON equivalent: ~2,340 bytes
// Protobuf encoded: 612 bytes (73% smaller)
Schema Evolution When You Can't Purge 300 PoPs Instantly
If you've ever tried to roll out a breaking schema change to an edge network, you know the pain. You can't atomically update 300 PoPs. For about 45 seconds — sometimes minutes if a region is degraded — you'll have old workers reading new cache entries and new workers reading old entries. Schemaless formats like MessagePack and CBOR handle this gracefully. They don't care if a field is missing; they'll just return what's there. We used MessagePack for our A/B testing config cache for years precisely because we could add an experiment_weights field without redeploying every edge worker first.
Protobuf is stricter, but that strictness is a feature when you need guarantees. With proto3, every field is optional and unknown tags are silently ignored, which gives you forward and backward compatibility if you follow the rules: never reuse tag numbers, only add optional fields, and never change a field's type. Where we got burned was renaming a field and reusing its tag number — old cached objects suddenly decoded into nonsense, and we spent a night purging edge caches manually. That experience pushed us to version our cache keys (e.g., user:{id}:v3) and treat serialization schemas as part of our Probabilistic Caching with Bloom Filters: Memory-Efficient Cache Lookups strategy, where the Bloom filter itself had to be serialized consistently across architectures.
CBOR sits in an interesting middle ground. It's defined in RFC 8949 and was built for constrained environments — think IoT and now, edge workers. It supports extensibility via tags, deterministic encoding for hashing, and even indefinite-length items for streaming. Unlike MessagePack, CBOR has a well-defined canonical form, which matters if you're signing cached responses or doing consistent hashing on the serialized bytes. I've found CBOR's diagnostic notation invaluable for debugging edge logs without paying the JSON size penalty in production. Here's how we now benchmark these differences before any format goes to production on our Cloudflare Workers / Fastly Compute@Edge fleet:
// bench_serialization.mjs - run inside an edge worker isolate
import { encode as msgpackEncode, decode as msgpackDecode } from '@msgpack/msgpack';
import * as cbor from 'cbor-x';
import { CachedResponse } from './generated/cache.js'; // protobufjs
const payload = { userId: 884732, ttl: 60, fragments: { hero: buf }, vary: ['accept','country'] };
bench('msgpack-encode', () => msgpackEncode(payload));
bench('cbor-encode', () => cbor.encode(payload));
bench('protobuf-encode', () => CachedResponse.encode(payload).finish());
// Measure decode + GC pressure under 128MB isolate limit
// Results feed into our capacity planning for
// /machine-learning-cache-warming-strategies
Measured Overhead: Size, CPU, and Cold Start on Real Edge Hardware
Synthetic benchmarks lie. On your MacBook, Protobuf always wins. On an edge isolate with a cold start budget of 5ms and no JIT warm-up, the story changes. We ran the same 1,184-byte JSON personalization object — a typical fragment for an e-commerce PDP — through all three formats on actual edge hardware (Xeon 8375C for compute and ARM Neoverse-N1 for some PoPs). We measured payload size after compression (Brotli-11, which we use for cache storage), encode time, and decode time on a cold isolate. The numbers below are median of 10M iterations; p95 was about 1.8x median for all formats.
What surprised me was how CBOR narrowed the gap with Protobuf once compression was applied. Protobuf's raw bytes are smallest, but MessagePack and CBOR compress surprisingly well because their type prefixes are highly repetitive. For objects under 1KB, MessagePack's encode speed actually beat Protobuf's because there's no schema lookup — just a tight loop writing bytes. If your workload is write-heavy with short TTLs (like our real-time inventory cache), that encode speed matters more than the 15% size saving. We pair this data with guidance from web.dev Articles on edge performance budgets to decide per cache namespace which format we use.
Don't pick one format globally. We didn't. Our edge image optimization metadata — stable, structured, long-lived — lives in Protobuf and is decoded once per request with zero-copy. Our Machine Learning for Cache Warming: Predictive Prefetching That Actually Works feature store, which is schemaless and changes weekly as data scientists add signals, lives in CBOR. And legacy edge KV that still needs to be readable by Lua, Rust, and JavaScript workers uses MessagePack because every runtime has a solid library. The table below is what we share with every new engineer to make that trade-off explicit.
| Format | Encoded Size (bytes) | Size After Brotli-11 | Cold Decode (cold isolate) | Schema Required? |
|---|---|---|---|---|
| JSON (baseline) | 1,184 | 412 | 18.4 µs | No |
| MessagePack | 786 | 338 | 9.1 µs | No |
| CBOR (canonical) | 802 | 341 | 8.7 µs | No |
| Protobuf (proto3) | 612 | 298 | 6.2 µs | Yes |
Putting It Into Practice: Implementing Binary Serialization in Edge Workers
We’ve all been burned by the “just swap JSON for MessagePack” blog post that makes it look like a one-line change. At the edge, it’s never that simple. You’re not just serializing - you’re dealing with Workers that have a 128MB memory limit, no native filesystem, and cold starts that’ll murder your p95 if you pull in a heavy Protobuf runtime. My rule of thumb after rolling this out on Fastly Compute and Cloudflare Workers: pick your format based on your payload shape and your tolerance for tooling pain.
If your objects are dynamic, schemaless hash maps - think personalized API responses or A/B test variants - MessagePack or CBOR will save you without forcing a build step. We saw a 41% median size reduction moving a 18KB JSON session object to MessagePack, and decode time dropped from 1.8ms to 0.72ms on Workerd (128MB worker). Protobuf is king for fixed, high-volume structures like image metadata or video manifests, where we measured 2.4x faster deserialization than JSON.parse() and a 55% byte savings, but you’ll pay for it in schema management. Don’t forget to factor in where those objects live - how you shard them matters just as much as how you encode them, which is why understanding Consistent Hashing for CDN Cache Distribution: A Practical Implementation Guide is critical before you start writing binary blobs to 300 PoPs.
Here’s the pattern we use in production for safe, versioned reads with a JSON fallback. It avoids the classic cache poisoning problem where a new worker writes Protobuf that an old PoP can’t read:
// Edge Worker: versioned MessagePack with graceful JSON fallback
import { encode, decode } from '@msgpack/msgpack';
const CACHE_VERSION = 2; // bump on schema change
const CACHE_TTL = 3600;
async function getCachedObject(cache, key) {
const cached = await cache.match(key);
if (!cached) return null;
const version = cached.headers.get('x-serialization-ver');
const format = cached.headers.get('x-serialization-fmt');
const buffer = await cached.arrayBuffer();
try {
if (version === '2' && format === 'msgpack') {
return decode(new Uint8Array(buffer)); // ~0.7ms for 18KB payload
}
// Fallback for old PoPs or old objects
const text = new TextDecoder().decode(buffer);
return JSON.parse(text);
} catch (e) {
// Corrupted buffer? Don't crash the worker, just miss
console.warn(`decode failed v=${version} fmt=${format}: ${e.message}`);
return null;
}
}
async function putCachedObject(cache, key, obj) {
const encoded = encode(obj); // Uint8Array
const response = new Response(encoded, {
headers: {
'Content-Type': 'application/msgpack',
'x-serialization-ver': String(CACHE_VERSION),
'x-serialization-fmt': 'msgpack',
'Cache-Control': `max-age=${CACHE_TTL}`
}
});
await cache.put(key, response);
}
Advanced Patterns and Troubleshooting When Binary Goes Wrong
The moment you can’t purge instantly, schema evolution becomes your biggest headache. We learned this the hard way pushing a Protobuf field rename that left 12% of our PoPs serving undecodable blobs for 8 minutes - even after a soft purge. The fix is a dual-read/canary-write strategy: new workers write both v2 (binary) and keep reading v1 (JSON or old binary) for at least 2x your longest TTL. Never use `required` fields in proto3 and always reserve numeric field IDs. For CBOR and MessagePack, add a tiny envelope: `{v: 2, data: ...}` adds 3 bytes but lets you route decoders without header sniffing. If you’re tagging objects for later invalidation, keep your serialization format out of your cache key and in headers instead - it keeps your Cache Tag Design Patterns: Organizing Content for Granular Invalidation clean and prevents key explosion when you A/B test formats.
When things break, they break weird. The most common error I see is `RangeError: Invalid typed array length` or truncated buffers - almost always because someone called `cache.put()` with a string instead of an ArrayBuffer, or a WAF trimmed the response. If your CPU time spikes from 5ms to 25ms after switching to Protobuf, you probably bundled the full `protobufjs` library (86KB gzipped). Switch to the lite runtime with pre-compiled static modules (`pbjs --target static-module`) and you’ll drop to 14KB and cut cold start by 35-40ms. For latency-sensitive paths, we also had to disable CBOR indefinite-length encoding - it’s great for streaming but kills performance on edge runtimes that don’t support it natively.
And finally, don’t treat binary serialization as a replacement for proper purging. You’ll still need to invalidate serialized objects when the underlying data changes. We’ve moved to writing the serialization version into our surrogate keys, e.g., `product-12345 v2:msgpack`, so we can nuke just the old format without wiping the whole cache. It’s a lot more surgical than URL purges and pairs perfectly with the approach outlined in Surrogate Keys for Targeted Cache Purging: Beyond URL-Based Invalidation. If your hit ratio drops after deployment, check your `Vary` header first - we once varied on `Accept-Encoding` and `x-serialization-fmt` and accidentally halved our cache efficiency.
Frequently Asked Questions
Should I use MessagePack, CBOR, or Protobuf at the edge?
It depends on your schema stability. Use MessagePack if you want a drop-in JSON replacement with 30-45% size savings and minimal tooling - it’s ideal for dynamic objects. Choose CBOR if you need IETF-standard compliance, CBOR tags, or deterministic encoding for signing. Pick Protobuf when your data is highly structured and stable, and you need maximum speed (2-3x faster decode) and smallest payload (50-60% smaller than JSON), but be ready to manage .proto files and a build step.
Will binary serialization actually improve my edge cold start?
Yes, but only if you keep your decoder lean. A minimal MessagePack decoder is ~4KB gzipped and adds less than 5ms to cold start. In contrast, importing the full `protobufjs` reflection parser can add 80-120ms on Cloudflare Workers and Fastly Compute. We reduced cold start from 110ms to 62ms by switching to pre-compiled Protobuf static-code and tree-shaking MessagePack. Smaller payloads also mean faster KV and cache reads - we saw 18ms saved on a 50KB object fetched from R2.
How do I handle schema changes without purging the entire edge cache?
Never purge everything. Use versioned envelopes and dual-read logic. Write new objects with a header like `x-serialization-ver: 2` and keep your workers able to decode v1 and v2 for at least double your TTL. Reserve and never reuse field numbers in Protobuf. For MessagePack/CBOR, include a version byte in the payload itself. This lets old PoPs gracefully fall back to JSON or the previous binary version until natural expiry catches up.
Can I still use JSON for debugging if my cache is binary?
Absolutely, and you should. Keep a `?debug=expand` query param or an internal header like `x-debug: true` that forces your worker to decode the binary and return pretty-printed JSON. We also log the first 64 bytes of any failed decode buffer to our edge analytics - it’s saved us hours. Don’t store JSON and MessagePack under the same cache key without a version header; you’ll create non-deterministic cache hits that are a nightmare to troubleshoot.