Send a file between two devices with no internet, no cable, and no account.
Turn any file into an animated QR code stream on one screen, point the other device's camera at it, and the file is rebuilt locally. Nothing is uploaded. There is no server.
qrfiletransfer.app · Docs · FAQ
An air-gapped file transfer tool that runs entirely in the browser. The sending device paints QR codes on its screen; the receiving device reads them with its camera and reconstructs the file. The only channel is light between a screen and a lens, so the transfer works with every radio switched off.
It is useful in exactly the situations where normal transfer methods fail:
- A locked-down or isolated machine where USB is blocked and there is no network route.
- A phone with no cable that fits, or two devices from ecosystems that refuse to talk to each other.
- A guest network that blocks peer-to-peer traffic.
- Anywhere you do not want a file touching a third-party server at all.
If you searched for any of these, this is the tool: QR code file transfer · animated QR code file transfer · air-gapped file transfer · air gap data transfer · transfer a file between two devices without internet · transfer files without Wi-Fi · transfer files without a cable · transfer files without USB · offline file transfer · local file transfer · no-upload file transfer · no-server file transfer · secure file transfer · private / privacy-friendly file transfer · screen-to-camera data transfer · optical data transfer · phone to laptop file transfer · laptop to phone file transfer · iPhone to Android file transfer · PC to mobile file transfer · browser file transfer with no app · open source file transfer.
What it is not: a file-sharing service, a cloud drive, or a LAN transfer tool. Nothing is stored anywhere, and the two devices never join the same network.
- On the sending device, open the home page and choose a file.
- On the receiving device, open
/scanand allow camera access. - Press Start streaming. The sender goes fullscreen and begins cycling QR codes.
- Point the receiver's camera at the sending screen, keeping the whole code in frame.
- Check the six-character match code agrees on both devices, then save when the receiver reports the file is complete.
The stream repeats forever until you stop it, so a receiver that starts late still finishes. Only the receiving side needs a camera.
A QR code holds a couple of kilobytes at most, so a single code cannot carry a file. The file is split into many small pieces and streamed as a rapid sequence of codes — a one-way optical link at up to 30 fps.
The hard part is that cameras drop frames constantly: they blur, refocus, expose badly, and their shutters are not synchronised to the sender's display. A numbered "frame 1, frame 2, frame 3" scheme would need the receiver to catch every frame and then request retransmission of the ones it missed — impossible here, because there is no channel back to the sender.
The fix is a fountain code. The sender emits an endless stream of symbols where any sufficiently large subset reconstructs the file. The receiver does not care which frames it caught, only how many. Missing a third of them costs a little time and nothing else, and a receiver can join 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 (XORs of pseudorandomly chosen pieces) follow for whatever the camera missed. Every symbol is derivable from (seed, blockIndex, symbolIndex) alone, which is what allows a late start, out-of-order frames, and arbitrary gaps with nothing replayed.
19 bytes of overhead, QR byte mode, 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
Every frame carries a transfer ID because CRC-32 alone cannot reject a frame from a different transfer — a neighbouring sender or a restarted run produces frames that parse cleanly and would be XORed into the decoder as valid-looking equations.
A 120-byte descriptor frame is interleaved every 24 frames with what the decoder needs to bootstrap: K, symbol size, block layout, seed, lengths, and both hashes. It sits deliberately outside the fountain-protected object, because putting it inside is circular — you cannot decode the object without it.
Four gates, and the save button unlocks only when all four pass:
- CRC-32 per frame — rejects blur, glare and partial reads.
- Transfer ID match — rejects a well-formed frame from a different transfer.
- BLAKE3-256 of the compressed object, checked before decompressing.
- BLAKE3-256 of the original file — the bytes you save are proven identical to the bytes sent.
CRC-32 stays where it is genuinely strong: per frame. It is deliberately not used for whole-file verification, because 2⁻³² is far too weak when one accepted bad symbol corrupts the entire reconstruction.
Rates are raw optical payload, before coding overhead and before compression. Capacities were verified against the ISO/IEC 18004 codeword tables.
| Profile | QR | EC | Lanes | fps | Frame payload | Raw rate | Status |
|---|---|---|---|---|---|---|---|
| Safe | V20 | M | 1 | 15 | 647 B | 9.7 KB/s | stable |
| Standard | V25 | L | 1 | 30 | 1254 B | 37.6 KB/s | stable |
| Fast | V30 | L | 1 | 30 | 1713 B | 51.4 KB/s | stable |
| Turbo | V30 | L | 2 | 60 | 1713 B | 102.8 KB/s | experimental |
| Lab | V40 | L | 2 | 60 | 2934 B | 176.0 KB/s | experimental |
Four numbers that must not be conflated: raw payload rate (above), acquired rate (raw × measured acquisition), fountain goodput (acquired minus coding overhead), and file goodput (adjusted for compression). Only the first is a specification.
Turbo and Lab are specified but not validated on real display-to-camera hardware, and the UI says so. requestAnimationFrame is not a scanout guarantee, and camera row exposure is unsynchronised with display scanout. Lab on a 1920px display gives each module about 5 device pixels, which is thin.
Practical expectation: a document moves in seconds, a photo in tens of seconds. This is the wrong tool for a video file — if you have a cable or a network, use it.
- 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 multiplier (8MB→24MB, 32MB→72MB, 128MB→384MB, using
process.memoryUsage().arrayBuffersbecause typed arrays live outside the V8 heap andheapUsedreports nonsense for them). An earlier draft claimed 512 MiB, which needs ~1.5 GB live and is killed by mobile WebKit long before it gets there. Raising the cap needs a streaming OPFS-backed pipeline, not a bigger constant — a test asserts cap × multiplier stays under 256 MB so this cannot be quietly forgotten. - 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. Encrypt the file first if you need confidentiality against an observer in the room.
- No sender authentication. The hashes travel over the same unauthenticated optical channel as the data, so an attacker controlling the display could substitute both. The hashes prove integrity, not origin.
- Open loop. No receiver-to-sender channel in v1, so no calibration and no in-flight adaptation. The receiver owns all progress state and tells the user what to do — the human is the feedback channel. A reverse QR ACK is the v2 path.
- No telemetry, no database, no server state. The Worker serves static assets and nothing else.
- Ultrasonic audio control channel deferred. AirCode's own Table 2 measures it at a 3.3% goodput gain — almost exactly what the QR beacon it would replace costs — against real WebAudio complexity.
- Zero third-party origins. Every CSP directive is
'self'or'none'. No analytics, no font CDN, no error reporter. A test fails if an external host reappears. - No webfont at all — the platform system face is used, 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-streamregardless of the MIME type the sender claimed. - Camera access only on
/scan, enforced byPermissions-Policy, and the stream is stopped on unmount. - Works fully offline after one visit, and the app audits its own cache rather than assuming — the offline badge reports what is genuinely present, including whether the decoder is cached.
Requires Node >= 22.13.
git clone https://github.com/Hitesh-Sisara/qr-code-file-transfer
cd qr-code-file-transfer
npm install
npm run dev # dev server
npm test # 517 tests
npm run check # tsc --noEmit
npm run build # production build
npm run preview # serve the production build locally
npm run deploy # deploy to Cloudflare Workers
npm run characterise # re-measure LT coding overheadThe build output is static, so any file server works — including one on a laptop with no internet connection. Deploying to a different domain needs only NEXT_PUBLIC_SITE_ORIGIN, which every canonical URL, the sitemap and robots.txt read from one constant.
Built on Next.js 16 (App Router) compiled natively to a Cloudflare Worker via vinext — no adapter, no Node runtime, no server.
| Package | Owns |
|---|---|
lib/protocol/ |
frame format, descriptor, CRC-32, resource bounds, block planning |
lib/fountain/ |
systematic LT code, PRNG, frozen degree table, peeling + bounded GE decoder |
lib/transfer/ |
open-loop carousel sender, latching receiver, packing, hash gate |
lib/profiles/ |
immutable profile registry, capacity arithmetic, geometry checks |
lib/qr/ |
verified capacity table, integer-scale renderer, degradation harness |
lib/content/ |
documentation content model, Markdown emitter, llms.txt generation |
lib/security/ |
the CSP and header policy, as data, with guard tests |
app/ |
routes, client islands for the two roles, docs and FAQ pages |
Everything under lib/ (except lib/content's renderer) is pure TypeScript with no DOM and no framework imports. That is not incidental: it is why swapping SvelteKit for Next.js moved this code unchanged with all its tests still passing.
Two primitives are deliberately borrowed, because both are well-specified, heavily exercised, and unrewarding to reimplement:
qrcode(MIT) for QR matrix generation — Reed–Solomon over GF(256), block interleaving, mask penalty scoring.zxing-wasm(Apache-2.0) for detection — finding a rotated, perspective-warped, blurred code in a camera frame.
A from-scratch QR encoder was written and then abandoned on evidence: its finder patterns, timing patterns, format bits and version bits matched a reference exactly, but the data region diverged in 6860 of 9409 modules. Since detection was always going to be borrowed, owning the RS tables buys almost nothing while a subtle encoder bug corrupting real transfers costs a lot.
The design originally asserted "~5% coding overhead". That was a hypothesis, so it was measured instead (scripts/characterise-overhead.ts, full numbers in docs/measured/):
- Clean start: 0.0% overhead at every K from 1 to 2000, p50 through max, zero failures. The systematic prefix pays off exactly as intended.
- Repair symbols only (receiver missed the prefix): p50 62%, p95 109% at K=2000, with a long tail — K=250 worst case needed nearly 3× the symbols. Plain LT has no precode, so unlike RaptorQ there is no guaranteed bound.
That second number is honest but not the operating case: the carousel repeats the prefix every revolution, so a late receiver waits one pass instead of grinding on repairs. Measuring it also caught two real bugs — a GE rate limiter inflating small-K overhead, and pathological K=2 blocks the planner now refuses to create.
npm test # 517 testsTests are written to be able to fail. Guards are mutation-verified: removing the peeling XOR, the GE rank check, the equation cap, the transfer-ID isolation, or the assemble completeness gate each breaks a specific named test. Several tests assert failure — extreme blur must decode nothing, and a starved transfer must report incomplete rather than hand over a partial file.
lib/transfer/e2e.spec.ts runs the whole pipeline: pack → compress → hash → fountain → frame → QR matrix → rendered bitmap → simulated camera degradation → real ZXing WASM → reassemble → verify → unpack, then compares bytes.
The degradation harness is honestly labelled: it is an image-pipeline test, not an optical one. It cannot exercise display rasterization, autofocus, auto-exposure, PWM flicker, moiré, or rolling shutter. That is why dense profiles stay experimental until measured on real hardware.
Config that cannot take effect is treated as a bug in its own right, because three separate instances of it shipped silently: a svelte.config.js whose adapter options were ignored for eighteen commits, a _headers file the local server never honoured, and a CSP duplicated in next.config.ts that diverged from the shared policy. Guard tests now cover each — the header policy is imported rather than restated, and a test fails if the literals return.
Verified: 517 unit and integration tests; the full pipeline against real ZXing WASM; every route (including /docs, /faq, robots.txt, sitemap.xml, llms.txt and the Markdown mirrors) serving under the production build; the client bundle grepped for things that must not ship.
Not verified, and stated as such in the code: dense dual-lane profiles on real display-to-camera hardware, and whether Cloudflare serves /zxing_reader.wasm as application/wasm in production — locally vinext's static server overrides the header rule, and Emscripten's buffered fallback means decoding works either way.
- /docs — the technical write-up. Also as Markdown.
- /faq — 15 questions, answered plainly. Also as Markdown.
- /llms.txt and /llms-full.txt — machine-readable index and full corpus for language models, per the llmstxt.org convention.
All of these are generated from one content model (lib/content/blocks.ts), so the HTML and the Markdown cannot drift apart. A test asserts every inline string is renderable by both.
Issues and pull requests are welcome. See CONTRIBUTING.md — the short version is that changes to lib/ need a test that can fail, and any performance or capacity claim needs a measurement rather than an estimate.
The design was informed by AirCode (Qian et al., NSDI '21), widely cited for "1 Mbps screen-camera communication". Reading the paper closely, Table 2 and Figure 10c report throughput 1086 Kbps at 5% BER with goodput of 159 Kbps, and Figure 16a shows goodput saturating near 182 Kbps as cells shrink. It also needs a 120 Hz monitor, a phone within 90 cm and under ~15° off-axis, plus ORB visual odometry to hold screen corners inside 4 pixels.
AirCode spends its bit budget buying imperceptibility — hiding data in a video someone is already watching. This project does not need that; both devices belong to the user. Visible QR spends its budget on near-zero BER and free per-frame geometry instead, which is the better trade for file transfer.
Three ideas from the paper were adopted: moving metadata off the unreliable optical link (the descriptor beacon), rate adaptation driven by measured channel quality rather than guesses, and ROI/homography reuse with a full-detection fallback.
MIT. The borrowed primitives are MIT (qrcode) and Apache-2.0 (zxing-wasm).