↓ Skip to main content

Sending a file with light: fountain codes and animated QR

·2849 words·14 mins

There is a machine in a locked room with no network on it, and a file that has to get out. The realistic answer to that is a USB stick, which is why the interesting question isn’t whether you can beat a USB stick — you mostly can’t — but what the alternative has to look like before it’s worth reaching for at all.

qrdrop has a mode called beam for this, shipped in 0.2.0 and current as of 0.6.0. The sender animates QR codes on its screen at roughly ten frames a second; the receiver points a camera at them. Nothing crosses a wire, no radio comes on, and there is no signalling of any kind between the two devices. It’s the same package as the WebRTC transfer and almost none of the engineering is shared, because removing the network removes something much more specific than bandwidth: it removes the back channel.

Sequence diagram of beam mode. The sender screen emits about ten QR frames a second, roughly 600 bytes each, every one an LT-coded block, to the receiver camera. The manifest is woven in every 20 frames so a receiver can join mid-stream. There is no back channel at all: the sender cannot tell how the receiver is doing except by looking at it. Sequence diagram of beam mode. The sender screen emits about ten QR frames a second, roughly 600 bytes each, every one an LT-coded block, to the receiver camera. The manifest is woven in every 20 frames so a receiver can join mid-stream. There is no back channel at all: the sender cannot tell how the receiver is doing except by looking at it.

What a one-way channel takes away
#

Every transfer protocol you have ever used assumes it can ask a question. TCP asks “did you get that?” a few hundred times a second. HTTP asks “what encodings do you accept?” before it sends a byte.

A screen and a camera cannot ask anything. The sender is never told which frames were missed, whether anyone is watching at all, or what the receiver is capable of decoding. The only feedback is a human looking at the receiving device and deciding to keep holding the phone up. So every decision below is downstream of one property: the sender picks blind, forever.

That is not a bandwidth problem, it’s an information problem — and it changes the answer at four separate layers.

The obvious design, and the cliff it falls off
#

The obvious design is to number the chunks and loop them. Chunk 1, chunk 2, … chunk N, back to chunk 1. qrbeam, which is where the idea of adding this to qrdrop came from at all, does exactly this. It is correct, it is about fifty lines, and it is genuinely fine when nothing is dropped.

The trouble is that things are dropped, and the failure is not proportional. To finish, the receiver needs every one of N specific chunks — so once it has most of them, each new lap delivers mostly things it already has while it waits on the two or three it is still missing. That is the coupon collector’s problem, and completion costs about N·ln(N) frames rather than N. At N = 1500 it’s the difference between a two-minute transfer and a twelve-minute one, because a missed frame doesn’t cost you a frame, it costs you a full extra lap.

Frames are missed. jsQR needs 50–100 ms to decode one, so a phone browser manages about ten decodes a second against a display emitting exactly ten frames a second. There is no headroom in that. A hand that moves, a repaint caught mid-frame, an autofocus hunt — each one is a lap.

Needing enough frames rather than particular ones
#

The fix is a fountain code, specifically an LT code. Instead of carrying chunk i, each frame carries the XOR of some pseudorandomly chosen set of chunks. Whenever a frame’s set has exactly one unknown block left in it, that block falls out; XOR it away from every other frame that mentions it, and that may free another. This is peeling, and it runs until everything is solved or nothing more can be.

The property that matters is not the cleverness. It’s that a frame no longer has an identity worth waiting for: it doesn’t matter which frames arrived, only how many. A dropped frame costs one frame, not a lap. That single change is what makes the transfer survivable over a channel with no way to ask for a retransmit — and it’s the reason this is an 800-line codec instead of a chunk-and-cycle loop.

The measurement
#

Frames the sender has to emit before the receiver has the complete file, over 1,748 blocks — which is a 1 MiB payload at this frame size, since 1,048,576 ÷ 600 rounds up to 1,748:

Grouped column chart, frames emitted before the file is complete, over 1,748 blocks of a 1 MiB payload, at four frame-loss rates. A fountain code costs 1.05, 1.48, 2.08 and 2.81 times N at 0%, 10%, 30% and 50% loss; numbered chunks on a loop cost 1.00, 8.3, 10.7 and 14.9 times N over the same range. The loop's columns jump almost eightfold between 0% and 10% and keep climbing, while the fountain code's stay short across the whole chart. The same numbers are in the table below. Grouped column chart, frames emitted before the file is complete, over 1,748 blocks of a 1 MiB payload, at four frame-loss rates. A fountain code costs 1.05, 1.48, 2.08 and 2.81 times N at 0%, 10%, 30% and 50% loss; numbered chunks on a loop cost 1.00, 8.3, 10.7 and 14.9 times N over the same range. The loop's columns jump almost eightfold between 0% and 10% and keep climbing, while the fountain code's stay short across the whole chart. The same numbers are in the table below.
frame lossthis codecnumbered chunks on a loop
0%1.05 × N1.00 × N
10%1.48 × N8.3 × N
30%2.08 × N10.7 × N
50%2.81 × N14.9 × N

How that was produced, since a table with no method attached is just a claim: a seeded PRNG decides whether each emitted frame reaches the decoder, at a fixed loss rate; the decoder is fed the survivors; the count is frames emitted until it reports complete. Seeded, because a flaky erasure-code benchmark is worse than none — it trains you to re-run until it passes, which is precisely how a real decoder stall gets waved through.

What the committed test suite pins is the 30% case, on a smaller payload, as a bound rather than a constant: test/beam.test.mjs fails if the emitted count is not under half of N·ln(N). That is deliberately a regression guard on the shape, not an assertion of the numbers above, which are offered as measured rather than as reproducible-to-the-decimal from an npm test.

Read the shape rather than the constants in any case. Cycling doesn’t degrade, it falls off a cliff the moment anything at all is lost — the jump from 1.00× to 8.3× happens between 0% and 10%, because ln(N) is the price of collecting the last few coupons and you begin paying it as soon as there are last few coupons to collect. The fountain stays inside a small factor across the whole range.

The systematic prefix, which is a trade and not a win
#

Here is the one real departure from the closest prior art, and it costs something.

A pure fountain pays its overhead even when nothing goes wrong: you always need somewhat more than N frames to recover N blocks, because some arrive carrying nothing you can peel yet. For small N and a robust soliton distribution that overhead is genuinely bad — 1.3–1.5× for a handful of blocks.

So the first N frames are sent systematically: block 0 plain, block 1 plain, and only then does the fountain start. A clean capture therefore costs exactly N frames and nothing more, which is the common case on a decent camera. Nothing downstream needs to know about the special case — a systematic frame is just a degree-1 frame whose single index the decoder derives the same way it derives every other set.

The cost is real and shows up under loss. With a systematic prefix the decoder needs about 1.3 distinct frames per block, against the ~1.15 a pure LT code reaches. By the time the fountain starts, most blocks are already solved, so a degree-d frame drawn against all N carries far fewer unknowns than its degree suggests. You are paying for degree you can’t use.

Which side of that trade is right depends entirely on how good you expect the camera to be, and this bets on it being good. That’s a bet, not a proof, and the number that would falsify it sits in the README next to the table.

The frame is a line of text
#

A beam frame is a QR code, and a QR code is a string, so the wire format is pipe-delimited text:

QRD1|D|<session>|<seed>|<base64url payload>|<crc32>

The sizing runs backwards from the QR. A version-20 code at error correction level L holds 858 bytes in byte mode, the header costs about 50 characters, and base64url inflates the payload by a third. 600 raw bytes lands just inside that, and keeps every frame at a module count a phone can lock onto from across a desk. Raising it pushes the QR version up, and a denser code is one that takes three attempts to scan — which costs far more than the bytes gained.

The seed is the interesting field. A frame is the XOR of a set of blocks and the decoder has to know which set. The obvious encoding is a list of indices, but a degree-40 frame would then need forty numbers in its header — at 600 bytes a frame, a meaningful fraction of the payload. Instead the frame carries an eight-character seed, and both halves run the same PRNG, splitmix32, to regenerate the set. Eight characters whatever the degree.

That makes the generator part of the wire format. Changing splitmix32 breaks compatibility exactly as surely as renaming a field would, which is why it’s pinned in the codec rather than reached for from somewhere general, and why the protocol tag QRD1 exists to be bumped the day a decoder of the previous version would misread a frame.

The degree comes from a robust soliton distribution, precomputed once per transfer as a CDF (c = 0.03, delta = 0.05, the standard constants for N in the hundreds-to-thousands). The ideal soliton distribution has exactly the right expected behaviour and is useless in practice: it expects precisely one peelable frame at every step, and peeling stalls the moment variance takes that away. The robust version adds a spike of extra low-degree frames to keep the ripple populated, for a few percent more frames overall.

CRC32, and why it is not a hash
#

Every frame ends in eight hex characters of CRC32, and that is not a security control.

What it defends against is a QR decoder handing back a plausible-looking string it got slightly wrong — a real failure mode when a camera catches a frame mid-repaint. One corrupt block XORed into the peeling state silently poisons every block that depends on it, and the damage surfaces minutes later as a SHA-256 mismatch on the whole file with nothing to point at. CRC32 catches that at the door for eight characters.

It is not a forgery defence and nothing pretends it is. An attacker who can put frames on the screen the receiver is pointed at has already won, by definition.

The manifest, woven in
#

The receiver can do nothing with a data frame until it knows N and the block size — and it cannot be assumed to have been pointed at the screen when the sender started. So the manifest isn’t sent once at the front; it’s interleaved into the cycle every 20 frames.

Sending it once would be free, and would strand anyone who didn’t catch the first two seconds of the loop. Every 20 frames means a receiver joining at any moment waits about two seconds at most, for roughly a 5% cost in frames. In a mode where the receiver is a person picking up a phone, that is not a close call.

gzip, specifically not brotli
#

Payloads are gzipped first, and the compressed result is kept only if it actually shrank. Text, CSV, JSON and source typically compress 3–10×; a 900 KB JPEG doesn’t shrink and is refused against the cap.

Brotli is the better algorithm — 15–20% smaller on text at this size — and no longer a dependency question either, since the WHATWG Compression Standard now lists it as a CompressionStream format. It is still the wrong choice here, for a reason particular to this mode: a one-way channel cannot negotiate. The sender picks blind, and the receiver either can inflate it or cannot.

gzip has been baseline in every browser since May 2023. Brotli is Safari 18.4+ and Firefox 147+, with Chrome — the likely sender and a very common receiver — behind. A sender choosing brotli hands some phones a file they structurally cannot open, and the only remedy the UI could offer is “try a different browser”. Twenty-five seconds off a three-minute transfer is a bad trade when the failure mode is total.

This is why the manifest’s encoding field is a string rather than a boolean. When Chrome ships brotli, preferring it is a feature-detect and one string.

There is a guard on the other side of it, too: a manifest may not declare an original size above 64 MiB, and the inflate is stopped the moment it exceeds the size the manifest declared. The manifest arrives from the peer exactly as the filename does, and this codebase treats that as hostile input — a decompression bomb being the obvious attack on “we will inflate whatever you tell us to”.

Roughly 6 kB/s, and a cap that is admitted rather than hidden
#

Beam runs at about 6 kB/s, with a 1 MiB cap applied after compression. At that rate the cap is already a three-and-a-half minute transfer, so it’s a limit on patience as much as on anything technical.

Minutes is also long enough to meet a failure that has nothing to do with coding theory. A beam is one device showing a code to another device’s camera with nobody touching either screen, which is exactly what a display timeout counts — so until 0.5.0 the code being read could go dark part-way through. The display is now held awake from the moment a transfer starts until it ends, and taken back after a glance at a notification, which the platform would otherwise not return.

The second reason is structural: a fountain code has no ordering. The decoder cannot write anything to disk until peeling completes, because any block may turn out to be needed to solve any other, so every block is held in memory until the last one arrives. That is the bill for not needing particular frames.

Raising the cap would want fountain-coding independent ~256 KiB windows, so memory stays bounded and each window can be flushed as it solves. That is not built. It roughly doubles the protocol’s moving parts to raise a cap that a 6 kB/s transfer rate makes academic — written down as a known limitation rather than left for someone to discover.

Beam is not encrypted, and it cannot be
#

This needs its own section rather than a footnote, because everything else qrdrop publishes about confidentiality describes the WebRTC path and stops at this mode’s edge.

There is no handshake here, so there is no ECDH, no forward secrecy, and no short-authentication-string check. There is no peer to authenticate — only photons. The only adversary that exists in this mode is someone who can see the screen, against whom a key displayed on that same screen is worth nothing.

The beam screen: a banner reading “This is not encrypted, and cannot be”, a dense QR code mid-animation, a speed control, and a note to keep the code on screen. The beam screen: a banner reading “This is not encrypted, and cannot be”, a dense QR code mid-animation, a speed control, and a note to keep the code on screen.

Both beam screens say so, the README carves the mode out of the threat model explicitly rather than letting the document’s other claims appear to cover it, and the changelog entry that shipped it led with the limitation. Beam is offered as an alternative to a USB stick on an air-gapped machine, not as a private channel — and a mode that looked as protected as the WebRTC path while not being so would be worse than having no offline mode at all.

For the same reason there is no qrdrop beam command and there will not be one: the mode needs a screen to animate and a camera watching it, and a terminal on the receiving end has neither.

The desktop app wraps the same page, so beam is there too — and on Linux, where WebKitGTK ships no RTCPeerConnection, it is the only way the app can move a file at all.

Prior art, and what is actually mine
#

Neither the idea nor the design is original here, and it would be tidier but dishonest to present them that way.

The prompt was qrbeam, which sends a file offline as animated QR codes to an iOS receiver. That is where the idea of adding this to qrdrop came from at all. Its wire format numbers the chunks and loops them, which is what the table above compares against — named, because a benchmark against an unnamed strawman is worth less to a reader and unfair to the party being measured.

txqr by Ivan Daniluk got to the fountain code first, and is the closest prior art to what’s built here: animated QR frames carrying LT-coded blocks, so the receiver needs enough frames rather than particular ones. Daniluk’s write-up on fountain codes and animated QR is the better explanation of why LT codes suit this problem at all, and is worth reading before this post. The reasoning above was arrived at independently, which makes it convergent rather than novel — no code was taken from either project.

What differs is small, and worth stating plainly rather than dressing up: the first N frames are systematic, compression is decided by measurement rather than assumption, the manifest is interleaved so a receiver can join mid-stream, and both halves run in a browser with no install on either side.

That last one is the part I’d defend. The coding theory here is decades old and better explained elsewhere. What’s mine is the set of trades made against one specific one-way channel, each with the number that would have changed the answer written down beside it.

Open share.stan-ely.com on your laptop and point your phone’s camera at it.