# Beam: no network at all Everything else in qrdrop assumes a network. On an air-gapped machine there is none, so there is no transfer. **Beam** is the answer to that, and it is a separate mode rather than a fallback: the sender animates QR codes on screen, the receiver points a camera at them, and nothing crosses a wire. 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. Beam is reached from the browser UI only — `qrdrop web`, then the page's "No network? Show it as a QR code" or "Scan a beamed file". There is no `qrdrop beam` command, and there will not be one: the mode needs a screen to animate and a camera watching it, which a terminal on the receiving end does not have. > [!WARNING] > **Beam is not encrypted, and it cannot be.** There is no handshake, so there > is no ECDH, no forward secrecy, and no SAS — there is no peer to > authenticate, only photons. The only adversary is someone who can see the > screen, and a key shown on that same screen does not stop them. The UI says > so in full before either side commits — on the sender's confirm sheet before > the code starts, and on the receiver's sheet before Accept — and both beam > screens carry a “Not encrypted” tag for the rest of the transfer. Everything > the [threat model](threat-model.md) says about confidentiality applies to the > WebRTC path and not to this one. ## What to expect About **6 kB/s**, and a **1 MiB cap** applied after compression. Files are gzipped first and the result kept only if it actually shrank, so text, CSV, JSON and source typically compress 3–10× and a several-megabyte log file is fine, while a 900 KB JPEG is refused. `test/beam.test.mjs` pins the loss behaviour, including the case where a manifest under-declares its own decompressed size. gzip rather than brotli, though brotli is smaller and is now in the WHATWG Compression Standard: a one-way channel cannot negotiate. The sender picks blind and the receiver either can inflate it or cannot, and brotli is Safari 18.4+ and Firefox 147+ with Chrome behind. Twenty-five seconds off a three-minute transfer is not worth a failure whose only remedy is "try a different browser". A fountain code has no ordering, so the decoder holds every block in memory until peeling completes — the second reason for the cap. Raising it wants independent ~256 KiB windows so memory stays bounded and each can be flushed as it solves; that is not built. ## Why a fountain code The obvious design is to number the chunks and loop them forever, as [qrbeam](https://www.npmjs.com/package/qrbeam) does. That is a coupon-collector problem run in laps: every lap offers each missing chunk one more chance, and the transfer ends only when the unluckiest of N chunks has finally been seen, which takes about `ln(N) / ln(1/loss)` laps. A missed frame costs a lap, not a frame. Frames *are* dropped — jsQR needs 50–100 ms per frame, so a Firefox phone manages about ten decodes a second against a display emitting exactly that. The transfer is an LT code instead, so a frame does not care *which* frames were missed, only how many arrived. Measured over 1748 blocks (a 1 MiB payload), frames the sender must emit before the receiver has the file, as the mean of 20 seeds with the lowest and highest in brackets: | frame loss | this codec | this codec, bursty | numbered loop | loop, bursty | | --- | --- | --- | --- | --- | | 0% | **1.05×** | **1.05×** | 1.00× | 1.00× | | 10% | **1.58×** (1.46–1.73) | **1.61×** (1.47–1.82) | 3.89× (2.90–5.87) | 3.12× (1.96–5.96) | | 30% | **2.06×** (1.96–2.13) | **2.05×** (1.88–2.17) | 7.02× (5.41–10.14) | 6.01× (4.27–8.41) | | 50% | **2.79×** (2.66–2.90) | **2.75×** (2.58–2.90) | 11.22× (8.70–14.52) | 11.08× (8.35–17.85) | `node scripts/bench-beam.mjs` produces every number here. An earlier version of this table gave the loop as 8.3 / 10.7 / 14.9 × N, which is what chunks picked *at random* cost, not an in-order loop; the script measures that model too, so the difference stays visible. "Bursty" is losses that arrive in runs averaging eight frames, at the same overall rate — what a phone camera does when it hands the decoder only its newest frame. The fountain code does not notice, because it only counts arrivals, and a random run of lost chunks costs the loop nothing extra either. What does break the loop is rhythm: a decoder that stalls on a fixed beat whose period divides N misses the same chunks on every lap and **never finishes** (one frame in every four, against N = 1748, in all 20 runs), while the fountain code completes at 1.96 × N. The first N frames are the source blocks sent plain, and only then does the fountain start. txqr does not do this, and the trade is real rather than a free win: the decoder then needs ~1.35 distinct frames per block under loss, against the ~1.13 a pure LT code reaches (both measured by the same script), because most blocks are already solved by the time the fountain begins and a degree-d frame therefore carries fewer unknowns than its degree suggests. What it buys is the common case — a clean capture costs exactly N frames and nothing more. Which side of that is right depends on how good you expect the camera to be, and this bets on it being good. ## Prior art, and what is actually ours 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](https://www.npmjs.com/package/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. 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 is unfair to the party being measured. **[txqr](https://github.com/divan/txqr)** by Ivan Daniluk got to the fountain code first, and it is the closest prior art to what is built here: animated QR frames carrying [LT-coded](https://en.wikipedia.org/wiki/Luby_transform_code) blocks, so the receiver needs *enough* frames rather than *particular* ones. The [write-up on fountain codes and animated QR](https://divan.dev/posts/fountaincodes/) is the better explanation of why this works and is worth reading before this section. The reasoning above was arrived at independently, which makes it convergent rather than novel — no code was taken from either project. What differs here is small and worth stating plainly rather than dressing up: the first N frames are systematic, compression is decided by measurement, the manifest is interleaved so a receiver can join mid-stream, and both halves run in a browser with no install on either side.