writing/blog/2026/07
BlogJul 31, 2026·6 min read

Animated QR Codes Are a Real File Transfer Channel

Fountain codes turned animated QR codes into a 129 KB/s file transfer channel with no network, no pairing, no app. Here is how the optical link actually works.

A demo went around this week that looks like a magic trick. One phone plays an endless stream of flickering QR codes. A second phone points its camera at the first screen. Twenty seconds later, the file is on the second phone.

No Wi-Fi. No Bluetooth. No pairing. No shared network. No app store. The only thing crossing the gap is light.

The project — Decimen Optical Transfer, MIT-licensed, built overnight with Claude Code — clocked roughly 129 KB/s of goodput against a theoretical ceiling near 186 KB/s. That is around 30 times faster than the 2024 sequential-QR implementations that ran near 4 KB/s. It is not a party trick. It is a genuine data channel, and the thing that makes it work is a family of erasure codes most web developers have never had a reason to touch.

The problem the optical channel creates

Every network protocol you have written assumes a back-channel. TCP retransmits because the receiver can say "frame 47 never arrived." HTTP range requests exist because the client can ask for a specific byte offset.

A screen showing a QR code to a camera has none of that. It is a pure broadcast. The sender has no idea whether the receiver is watching, whether the lighting is bad, whether the camera dropped a frame to autofocus, or whether the receiver started watching halfway through.

The naive design is to chunk the file, number the chunks, and loop the sequence. This is what almost everyone builds first, and it fails badly. Phone cameras miss frames constantly — a lost frame at 15 FPS means waiting a full loop for that chunk to come around again. Miss a few chunks per loop, and the transfer never converges. The original txqr project measured this in 2018: at chunk sizes near 1000 bytes, a missed frame was "almost guaranteed" and the decoder would sit in a timeout loop forever.

You cannot fix this with retries, because there is no channel to retry over.

Fountain codes: throw away the sequence numbers

Fountain codes solve exactly this. The formal name is rateless erasure codes, and the intuition is in the metaphor: a fountain sprays an unlimited number of droplets. You do not care which droplets you catch. You just hold out a bucket until it is full.

Instead of transmitting chunk 1, then chunk 2, then chunk 3, a Luby Transform encoder does this:

  1. Split the file into K source blocks.
  2. For each frame, pick a random degree d from a carefully tuned distribution (the robust soliton distribution).
  3. Pick d random source blocks and XOR them together.
  4. Transmit that XOR result, plus enough header to derive which blocks went into it.

Every frame is a different scrambled mixture of the whole file. No frame is "the third chunk." The receiver collects frames in any order, and once it has gathered roughly K × 1.15 distinct frames, it can solve the system and recover the original — a 15 percent overhead in exchange for total indifference to which frames arrived.

The decoding is elegantly simple. Any frame with degree 1 is a source block outright. XOR that recovered block out of every other frame that included it, which reduces those frames' degree by one, which may produce new degree-1 frames, which cascade. It is a chain reaction.

// LT encoder — one frame, derived entirely from its sequence number
function encodeFrame(blocks, seq, sessionId) {
  const rng = seededRng(sessionId ^ seq);        // receiver reproduces this exactly
  const degree = robustSoliton(rng, blocks.length);
  const picked = sampleDistinct(rng, blocks.length, degree);
 
  const payload = new Uint8Array(blocks[0].length);
  for (const i of picked) {
    for (let b = 0; b < payload.length; b++) payload[b] ^= blocks[i][b];
  }
  return concat(header(sessionId, seq, blocks.length, payload.length), payload);
}

Note what the header does not contain: the list of source blocks. It carries only the sequence number, and the receiver re-derives the same random subset from the same seed. That keeps the header near 20 bytes instead of a variable-length index list — and on a channel where every byte costs QR modules, that matters.

It also creates a subtle trap the Decimen author hit: Math.log returns slightly different values in V8 and JavaScriptCore. If your degree distribution depends on floating-point math, an iPhone receiver and an Android sender derive different block subsets and every frame decodes to garbage. Deterministic cross-engine arithmetic is not optional here.

Where the throughput actually comes from

Goodput on an optical link is a product of four terms, and the naive instinct optimises the wrong one.

LeverNaive moveWhat actually works
Frame ratePush to 60 FPSMatch the camera's real capture rate, not the display's
QR densityMax out version 40Version 25 to 30 — dense enough to carry payload, sparse enough to decode under motion blur
Error correctionSet level H "to be safe"Level L (7 percent) — the fountain layer already handles loss
Chunk size1500 bytes for efficiency550 to 900 bytes; larger chunks push QR density past readable

That third row is the counter-intuitive one. QR codes have their own Reed-Solomon error correction, and raising it to level H burns 30 percent of every frame on redundancy. But you already have a loss-tolerant layer above it. A frame that fails to decode is simply a frame the fountain did not deliver, and the fountain does not care. Redundancy at the QR layer is redundancy paid twice.

Frame rate is where the platform quirks live. On iOS, requesting { ideal: 60 } from getUserMedia silently gives you 30 FPS; you need { exact: 60 } at 1280px width to actually get 60. And requestVideoFrameCallback is the right capture primitive — a setInterval polling loop will sample the same frame twice and miss others entirely.

// Receiver — decode every real camera frame, not every timer tick
function pump(video, canvas, ctx, onSymbol) {
  video.requestVideoFrameCallback(() => {
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    const result = zxing.readBarcode(
      ctx.getImageData(0, 0, canvas.width, canvas.height),
      { formats: ["QRCode"], tryHarder: false }   // tryHarder costs more than it recovers
    );
    if (result?.bytes) onSymbol(result.bytes);
    pump(video, canvas, ctx, onSymbol);           // re-arm
  });
}

Two practical notes on that snippet. tryHarder sounds free and is not — on a stream where the next usable frame is 16ms away, spending 40ms rescuing a marginal one is a net loss. And each requestVideoFrameCallback must be re-armed exactly once; if the camera stream is torn down and rebuilt without cancelling the chain, you end up with zombie callbacks decoding into a dead canvas.

Safari adds one more wrinkle: BarcodeDetector is still unimplemented in WebKit, so a WebAssembly build of zxing-cpp is the portable path. If you have been following WebAssembly's move beyond the browser, this is the mirror image — Wasm filling a gap inside it.

The current landscape

This idea has been reinvented repeatedly, which is usually a sign it is a good one.

ProjectCodecReported throughputNotes
Decimen Optical TransferLT, robust soliton~129 KB/sBrowser-based, MIT, July 2026
RaptorQRRaptorQ (RFC 6330) via Wasm180 to 254 KB/s (lab)Parallel QR playback, PWA, CLI sender
txqrFountain codes, Go~9 KB/s peakThe 2018 original; Gomobile and GopherJS
libcimbarFountain plus zstdHigh, files to 33MBDrops QR for a denser colour format

RaptorQR's numbers come from playing four QR codes side by side at 30 FPS on an iPhone 16 in controlled lighting — real, but a ceiling, not a promise. RaptorQ is also the more serious codec: it is a standardised systematic code with far better overhead characteristics than plain LT, at the cost of needing a real implementation rather than fifty lines of XOR.

libcimbar takes the honest next step and abandons QR entirely. Once you accept that you are building an optical modem, the QR spec — designed to be read from a printed label at an odd angle — is dead weight. Colour-coded dense grids carry far more per frame.

Where this is genuinely useful

It is easy to dismiss this as a novelty when AirDrop exists. The uses are real but specific.

Air-gapped operations. Signing a transaction on a machine that has never touched a network, then moving the signed blob out via camera, is an established hardware-wallet pattern. Fountain-coded animated QR raises the payload ceiling from a single static code to full files.

Connectivity gaps and shutdowns. Across much of MENA and Africa, mobile data is metered, expensive, or intermittently unavailable. A protocol that needs zero infrastructure — not even a local hotspot — degrades gracefully in a way that cloud sync does not. This is the same instinct behind local-first architecture: assume the network is a nice-to-have.

Cross-boundary transfer under policy. Plenty of enterprise and government environments forbid USB devices and block file-sharing services, but nobody has written a policy against pointing a phone at a screen. Whether that is a feature or a gap depends entirely on which side of the policy you sit.

That last point deserves the caveat the Decimen author makes himself: "air-gapped" here is optical, not adversarial. A camera pointed at a screen is a data path. Anyone who can see the screen — including a security camera, a window, or a reflection — receives the same broadcast. There is no pairing step and no authentication. If the payload matters, encrypt it before it becomes frames; the transport gives you nothing. The same discipline that applies to any untrusted channel applies here, and if you are thinking about the wider picture, our notes on digital sovereignty and sovereign infrastructure cover the policy side.

Trying it yourself

The whole stack is three parts: an LT or RaptorQ encoder, a fast QR renderer, and a Wasm barcode reader. RaptorQR is the most complete starting point — it ships a browser sender, a PWA receiver, and a CLI. Decimen is the most readable if you want to understand the codec rather than use it.

Start with the parameters that are known to work — version 25 QR, error correction level L, 700-byte chunks, capture driven by requestVideoFrameCallback — and tune from there. Measure decoded symbols per second, not frames displayed per second. The gap between those two numbers is your entire engineering problem.

The takeaway

The interesting thing here is not the throughput. It is that a constraint everyone treats as fatal — no back-channel, lossy medium, no handshake — turns out to have a clean, forty-year-old answer sitting in coding theory. Fountain codes were designed for exactly this shape of problem and are underused because most developers never meet a channel without retransmission.

Screens and cameras are the two most universal I/O devices on the planet. Every phone has both. Treating that pair as a transport layer is a genuinely good idea, and it took a rateless code to make it practical.


Sources: Decimen Optical Transfer analysis · RaptorQR · txqr · Animated QR data transfer