QR Code File Transfer

Documentation

How QR code file transfer works

The protocol, the reasoning behind it, and the limits — including the ones that are measured rather than claimed.

Also available as Markdown for LLMs and scripts, or as part of the full corpus.

Quick start

  1. On the sending device, open the home page and choose a file.
  2. On the receiving device, open /scan and allow camera access.
  3. Press Start streaming on the sender. It goes fullscreen and begins cycling QR codes.
  4. Point the receiver’s camera at the sending screen, keeping the whole code in frame.
  5. Check the six-character match code shown on both devices agrees, then press save when the receiver reports the file is complete.

Only the receiving side needs a camera. Any pair works: Mac to iPhone, Windows to Android, Linux to iPad, phone to phone.

What people call this

This category has no settled name, which makes it hard to search for. If you arrived looking for any of the following, this is the tool you were looking for:

  • QR code file transfer / animated QR code file transfer / QR code data transfer
  • Air-gapped file transfer, air gap data transfer, sneakernet replacement
  • Transfer a file between two devices without internet, without Wi-Fi, without a cable, without USB
  • Offline file transfer, local file transfer, no-server file transfer, no-upload file transfer
  • Private or privacy-friendly file transfer — nothing is uploaded, so there is nothing to leak
  • Screen-to-camera data transfer, optical data transfer, visual light data transfer
  • Phone to laptop, laptop to phone, iPhone to Android, PC to mobile file transfer

What it is not: a file-sharing service, a cloud drive, a chat app, or a LAN transfer tool. Nothing is stored anywhere, and no two devices ever join the same network.

How it works

A QR code holds at most a couple of kilobytes, so a single code cannot carry a file. The file is therefore split into many small pieces and streamed as a rapid sequence of codes — think of it as a one-way optical link running at up to 30 frames per second.

The hard part is that a camera will inevitably miss frames: it blurs, it refocuses, it exposes badly, and its shutter is not synchronised to the sender’s display. A naive "frame 1, frame 2, frame 3" scheme would need the receiver to catch every single frame, and then to ask for retransmission of the ones it missed — which is impossible here, because there is no channel back to the sender.

The fix is a fountain code. Instead of numbered pieces, the sender emits an endless stream of symbols where any sufficiently large subset can reconstruct the file. The receiver does not care WHICH frames it caught, only HOW MANY. Missing a third of the frames costs a little time and nothing else, and a receiver can join the stream halfway through.

Specifically it is a systematic LT code: the first pass sends the source data itself, so a clean read costs zero coding overhead, and repair symbols (XOR combinations of pseudorandomly chosen pieces) follow for anything the camera missed. Every symbol is derivable from (seed, blockIndex, symbolIndex) alone, which is what lets a receiver start late, take frames out of order, and skip any subset without replaying anything.

Frame format

Each QR code carries a binary frame with 19 bytes of overhead, encoded in QR byte mode with no Base45 layer:

byte  0      magic 0xA | protocol version
byte  1      frameType | laneId | profileId
bytes 2-9    transferId   uint64
bytes 10-11  blockIndex   uint16
bytes 12-14  symbolIndex  uint24
bytes 15..n  payload
last  4      CRC-32 over bytes 0..n

The transfer ID is not decoration. CRC-32 alone cannot reject a frame belonging to a different transfer — a neighbouring sender, or the same file restarted, produces frames that parse cleanly and would be XORed into the decoder as valid-looking equations, silently corrupting the result.

A 120-byte descriptor frame is interleaved every 24 frames, carrying what the decoder needs before it can decode anything: symbol count, symbol size, block layout, PRNG seed, file length, and both hashes. It sits deliberately OUTSIDE the fountain-coded object, because putting it inside is circular — you cannot decode the object without first knowing how.

How the file is verified

An optical channel is noisy, so a received file must be proven correct rather than assumed. Four gates run in order, and the save button unlocks only when all four pass:

  1. CRC-32 per frame — rejects blur, glare and partial reads before a frame reaches the decoder.
  2. Transfer ID match — rejects a well-formed frame from a different transfer.
  3. BLAKE3-256 of the compressed object, checked before decompression, so a corrupt stream can never be fed to the decompressor.
  4. BLAKE3-256 of the original file — the final gate. The bytes you save are proven identical to the bytes that were sent.

CRC-32 is used only where it is genuinely strong: per frame. It is deliberately NOT used for whole-file verification, because a 1-in-4-billion collision chance is far too weak when a single accepted bad symbol corrupts the entire reconstruction.

The six-character code shown on both screens identifies which transfer you are looking at — useful when two people are streaming in the same room. It is not an integrity proof; the two BLAKE3 hashes are.

Speed, and what the numbers mean

Rates below are raw optical payload — the bytes the codes carry, before coding overhead and before compression. Capacities were verified against the ISO/IEC 18004 codeword tables rather than assumed.

ProfileQR versionError correctionLanesfpsFrame payloadRaw rateStatus
SafeV20M115647 B9.7 KB/sstable
StandardV25L1301254 B37.6 KB/sstable
FastV30L1301713 B51.4 KB/sstable
TurboV30L2601713 B102.8 KB/sexperimental
LabV40L2602934 B176.0 KB/sexperimental

Four numbers get conflated in this space and should not be: raw payload rate (the table above), acquired rate (raw multiplied by how much the camera actually catches), fountain goodput (acquired minus coding overhead), and file goodput (adjusted for compression). Only the first is a specification; the rest depend on your screen, your camera and your file.

Practical expectation: a text document or a small PDF moves in seconds. A photo takes tens of seconds. This is not the right tool for a video file — and the honest recommendation, if you have a cable or a network, is to use it.

Limits and non-goals

  • 64 MiB maximum file size, and that number is measured. Preparing a transfer holds three full-size representations at once — the raw file, the compressed object, and the fountain source block — for a measured 3.0x peak memory multiplier. An earlier draft claimed 512 MiB, which would need roughly 1.5 GB live and is killed by mobile Safari long before it gets there. A test asserts cap × multiplier stays under 256 MB so the claim cannot quietly rot.
  • Anyone with line of sight can read the stream. The bytes never touch a network, but a QR code on a screen is readable by any camera pointed at it. This is privacy through locality, not encryption.
  • No sender authentication. The hashes travel over the same unauthenticated optical channel as the data, so someone controlling the sending display could substitute both. The hashes prove the file arrived intact; they do not prove who sent it.
  • One-way, open loop. There is no receiver-to-sender channel, so there is no calibration and no in-flight rate adaptation. The receiver owns all progress state and tells the user what to do — the human is the feedback channel.
  • No telemetry, no database, no server state. The Worker serves static assets and nothing else. There is no analytics script, no cookie, and no third-party origin in the CSP.

Privacy and security posture

The file is read by the browser, processed in memory, and painted to a canvas. It is never sent anywhere. There is no upload endpoint in the application because there is no server-side application — the deployment is a Cloudflare Worker that serves static files.

  • Zero third-party origins. Every CSP directive is 'self' or 'none'. There is no analytics, no font CDN, no error reporter. A test fails if an external host reappears.
  • No webfont at all, so not even a font request leaves the browser.
  • Received filenames are treated as hostile: path separators stripped, control characters removed, never rendered as markup, and the download blob is always application/octet-stream regardless of the MIME type the sender claimed.
  • Camera access only on `/scan`, enforced by Permissions-Policy, and the stream is stopped on unmount rather than left running.
  • Works fully offline after one visit. You can load the page, turn off every radio, and still complete a transfer — which is the only honest way to claim an air-gapped tool.

Run it yourself

The whole app is static. Clone it, build it, and serve the output from anything — including a laptop on an isolated network with no internet at all.

git clone https://github.com/Hitesh-Sisara/qr-code-file-transfer
cd qr-code-file-transfer
npm install
npm run dev      # local dev server
npm test         # the full suite
npm run build    # production build
npm run preview  # serve the production build locally

Requires Node 22.13 or newer. Deploying to Cloudflare Workers is npm run deploy; any static host works equally well, since there is no server-side code to run.