TL;DR: I set out to compress a shared whiteboard for a connection that's slow (~120 kbps practical) and drops without warning. The real problem wasn't compression. It was that I was sending information the other client already had. A byte ledger I built before adding audio is now producing numbers: shared audio sits around 40 kbps, leaving about 80 kbps for the whiteboard. The project turned into a lab (LOW-NET) around a bigger question: how little information does a shared experience need? Below: the progression, what's built, what's explicitly not measured yet, and the open questions.
Quick honesty note up front: this is the experiment I'm building, not a finished benchmark. Where numbers don't exist yet, I say so.
The constraint that started it
I work from Cuba. The practical working connection is around 120 kbps and it disappears and reappears at random: batch service, shared capacity, anything but a stable channel.
During an online English class, audio worked and live drawing didn't. A teacher can't show you how to write a phrase on a frozen screen.
That's the design environment. I design against an environment where roughly 120 kbps is a realistic working constraint and connectivity can be intermittent. The target comes from the environment itself, not from capping a link to generate a number.
Wrong model #1: compress the full state
First instinct, and a clean failure:
// "aaaaaaabbbb" -> [7, 'a', 4, 'b']
function rle(seq) {
const out = [];
let i = 0;
while (i < seq.length) {
let j = i;
while (j < seq.length && seq[j] === seq[i]) j++;
out.push(j - i, seq[i]);
i = j;
}
return out;
}
A black-and-white canvas is a grid of bits; run-length encoding eats the repetitions; done. Correct mechanics, wrong abstraction. Two people drawing don't need the drawing. They need what changed.
The pivot: represent changes, not states
The structural change:
{ type: 'add', shape } // a stroke is born
{ type: 'update', id, next } // a point moves
{ type: 'remove', id } // a stroke is erased
- Each client already holds the canvas; you send operations against it, not snapshots.
- This is the "do the work once, on the side that owns the state" move, and for a whiteboard it's the difference between the operation graph and the framebuffer.
- Old idea I re-derived the hard way: VNC (RFC 6143) refreshes only dirty screen regions. Same principle, different unit.
Serialization: binary + gzip, opportunistically
For the message layer I've been working with a custom binary encoding, gzip-compressed when it pays off (ArrayBuffer as the wire unit instead of a huge JSON). Two honest caveats:
- Compression has a fixed cost. Below some payload size, a compressor can inflate your data stack (headers, nothing to exploit). On a ~120 kbps channel that threshold matters, and it needs measuring, not guessing.
- "Binary + gzip wins over JSON" is still a working assumption here. The ledger I have measures whole-channel traffic, not per-representation comparisons, so I can't claim a crossover yet.
If you've got real thresholds for tiny payloads, I want them (see the end).
The byte ledger, and the first real number
Budget only means something if you can see it. Before adding audio I built a small system that registers the data being consumed and sent and lets me view it, so I could actually know whether I was inside the budget. 📊
Then I added shared audio, and it works. With a few participants, the audio channel registers around 40 kbps, leaving about 80 kbps for the whiteboard:
120 kbps total budget
-40 kbps shared audio
80 kbps whiteboard (and everything that comes after)
That is the first number with a name. It came from the ledger, not from a guess.
Audio, or: the part of the problem compression doesn't touch
Voice is what made the class human. The context worth knowing: codec design has been doing the hard version of this for decades. Opus is built for VoIP over constrained links. Its specification (RFC 6716) lands in the order of 6 to 510 kbps, and it ships with two ideas I keep coming back to:
- Discontinuous Transmission (DTX): stop transmitting while nobody speaks (opus-codec.org).
- In-band FEC: spend extra bits protecting against loss when the channel is lossy.
Both are "decide what not to send," which is the actual game. Compression is one corner of it.
Video, or: the delta of a face is enormous
Pixels are expensive. Video changes constantly even when nothing meaningful changes, and frames repeat whether the eye notices or not. Send only deltas applies, but a face generates huge deltas: lips, eyes, expression, every frame.
The uncomfortable question: what part of the video is information the other person needs?
Two tracks I'm exploring in parallel:
- Very low-resolution video: still pixel-based, still costly. For ideas I'm borrowing from ThePrimeagen's ASCII art, particle systems and ASCII Doom videos, and I'm even toying with something shader-like that would carry information through those ASCII algorithms. 🕹️
- Avatar / semantic representation: transmit body state instead of pixels:
{ head: { x, y, z, rot }, hands: [...], posture: 'seated' }
Same whiteboard intuition: find the minimum the other side needs, send that. The avatar currently works, although sending only the minimum makes it hard to carry the gestures of a human face in a congruent way. I don't yet know where it stops feeling human. That's a measurement on the list.
The assumption I had to unlearn: continuity
The bigger lesson wasn't bandwidth. A channel that does this:
available → degraded → unavailable → available → burst → unavailable → ...
behaves nothing like "120 kbps sustained." When there's no channel, compression does nothing. The question becomes what to do while you're disconnected: retain, prioritize, batch, and push when it returns.
My hypothesis is that this is a shared-capacity problem rather than a speed one: an antenna serving many devices in batches. I haven't verified it, and I offer it as what it is, an informal observation my model happens to fit, not a measurement. ⚠️
Related work I found: DTN
Researching "designing for a channel that can be absent" led me to Delay/Disruption Tolerant Networking. NASA's definition: an architecture for networks with disruptions, delays, and data-rate mismatches. Core mechanism: store-and-forward, hold the message, keep trying until there's a path.
This isn't something I invented: RFC 4838 (architecture) and RFC 9171 (Bundle Protocol v7) are public, and the field has decades of documented work.
Framing matters: my problem is not deep space 😅. LOW-NET is a handful of participants and a shared board, with delays of seconds rather than minutes. Implementing full Bundle Protocol v7 here would be over-engineering. The move was ingesting the ideas: no continuity as a first-class condition, not the protocol.
What I explicitly do NOT have yet
The numbers that would make this a benchmark post do not exist:
- no measured savings for full-state vs deltas;
- no measured binary-vs-JSON comparison on this codebase;
- no measured video/avatar quality-vs-bitrate curve;
- no reconnect/reconcile timing;
- no compression-threshold measurements on real payloads.
What I do have so far: the ledger itself, and the ~40 kbps audio figure it produced.
The measurement plan (in order)
- Byte ledger: built. Already produced the ~40 kbps audio figure.
- Full state vs delta: at what point re-sending everything beats chained deltas.
- Binary vs JSON: real sizes and parse times on actual payloads.
- Batching: grouping changes into bursts for the degraded channel.
- Reconnect: resume without duplicated work or data floods.
- Minimal store-and-forward: a local queue that holds and delivers when the channel returns.
- Audio: minimum tolerable quality tier. (Opus range above is the starting point.)
- Avatar vs low-res video: the semantic-vs-pixel trade-off.
- Prioritization: given a byte budget, what goes first.
Demo (draw in a room, stroke arrives): https://whiteboard-five-gamma.vercel.app. References: RLE, MDN ArrayBuffer, Opus (RFC 6716), VNC (RFC 6143), DTN (RFC 4838) (RFC 9171), ASCII experiments (ThePrimeagen). The lab is LOW-NET: an experiment in communicating meaning instead of unnecessarily communicating state.
What I'm asking
If you've measured compression thresholds on tiny payloads, built avatar-based presence, or run anything over genuinely broken networks, I want to compare notes. What surprised you? Where did your model break?
Top comments (2)
The framing I keep coming back to is yours: the real problem wasn't compression, it was sending information the other client already had. That ordering is what most people get wrong. I built the ledger first too, on a different sync problem, and it changed every decision afterwards — once you can see bytes per message type, the fix stops being 'compress more' and becomes 'send less, more often'.
On tiny payloads specifically, my measurement was that gzip stops paying for itself below a few hundred bytes: the deflate state and chunk framing eat the win. And the WebSocket frame header (2 to 14 bytes) becomes a real share of a 40-byte delta, so batching matters before compression does. The thing I'd watch with chained deltas is silent divergence — after a partial reconnect a client missing one delta keeps drawing, just wrong. Do you ack with a state hash so the server can tell who drifted, or is full-resend the recovery path?
That’s exactly the kind of feedback I was hoping this experiment would attract.
I haven’t implemented the state-hash/ACK part yet, so right now I don’t have a proper answer for divergence after a partial reconnect.
Your point about tiny payloads is especially interesting. I’m currently treating “compression” as one experiment among several, and I want to measure the actual wire cost, including ws framing, rather than just comparing payload sizes. A 40-byte delta with several bytes of framing changes the equation quite a bit.
The state-hash idea gives me another experiment I hadn’t explicitly separated yet:
delta stream → disconnect → lose one or more deltas → reconnect → detect divergence → recover with the minimum possible amount of data.
I’m leaning toward testing a few recovery strategies rather than immediately choosing one: state hash + missing-op detection, snapshot + tail, and full resend as the baseline since this happens a lot on my environment.
And I really like your observation that the ledger changes the question from “how do I compress more?” to “what can I avoid sending in the first place?” That’s becoming the central lesson of LOW-NET for me. 👌