All projects

Shardrop

Chunked, verified, resumable file transfer straight between two browsers.

Role
Solo — design, implementation, deployment
Year
2026
Status
Deployed and in use
TypeScript
WebRTC
Web Crypto
OPFS
Web Workers
Node.js
Vite
Playwright
Fly.io
Vercel
A 114 MB file part-way through: 252 of 458 shards verified and stored, 2.8 MB/s, nothing resent
One tile per shard, green once its hash has been checked on arrival — amber if it had to be resent.

The problem

Sending someone a large file means uploading it to a server that keeps a copy, then having them download it. The file comes to rest on infrastructure neither party controls, which is a privacy problem, a cost problem, and — for a 5 GB file on a hotel network — a patience problem.

WebRTC can move bytes directly between two browsers. The interesting question is not whether a browser can open a peer connection; it can, in about thirty lines. It is everything that follows. A DataChannel message cannot exceed a size the connection negotiates. A tab cannot hold 5 GB in memory. Networks drop and tabs reload, and a transfer that restarts from zero at 90% is useless. Two browsers on different networks cannot find each other unaided. And nothing about the word “direct” is self-evident to the person using it — they have to be told whether their file really went peer to peer.

How it works

Nine browser modules, none of which touch the DOM, and one small relay. Correctness never depends on anything that was rendered.

Sending browser

File or folder

never fully in memory

Chunk + hash

Blob.slice, then SHA-256

Manifest

fileId, every shard hash

WebRTC DataChannel

SCTP over DTLS

Receiving browser

Verify + store

rehash, then store at offset

Saved file

assembled in place

Everything above happens between the two browsers.

Servers — none of them can read the file

Signaling relay

sealed offer + answer

STUN

your public address

TURN

only if direct is blocked

The relay is released the moment the two devices connect. A relayed connection forwards the same encrypted packets, and is labelled relayed.

A shard is the unit of hashing, storage and resume; underneath it, frames are sized at runtime from pc.sctp.maxMessageSize because a DataChannel message cannot exceed what the connection negotiated — 256 KiB in Chromium.

Three decisions worth defending

A logical shard is not a wire message

The SDP for a real connection advertises a=max-message-size:262144; Firefox advertises far more. So frame size is read from pc.sctp.maxMessageSize at runtime, and a 1–25 MB shard is cut into frames underneath it. Two layers, two reasons: the shard is the unit of hashing, storage and resume; the frame is the unit the transport will actually accept.

An ACK is not about delivery, so silence is its own failure

SCTP already guarantees delivery and ordering, so a per-shard hash does not defend against network corruption — it defends against bugs, storage faults and a hostile peer. Likewise ACK means hashed and durably stored, the only fact resume can be built on. That has a sharp consequence: a corrupted shard produces a RETRY, but a shard lost in flight produces nothing at all, because the receiver never knew it existed. Unacknowledged shards are requeued on a timeout. A test that dropped a shard and watched the sender wait forever found this, not the design.

One pairing code, two derived secrets

Pairing across networks needs a relay, and a relay is a party that could rewrite the SDP — which carries the DTLS fingerprint that authenticates the “direct” connection. So one 80-bit code derives both the relay’s room id and an AES-256-GCM key through HKDF. The relay sees an opaque room id and ciphertext it can neither read nor forge, and the code travels in the URL fragment, which browsers never send to a server. What that does not prove is who holds the code, so both screens also show four words derived from the two certificates.

Measured, not assumed

1.1 GB/s

SHA-256 throughput measured in Chromium — which disproved the plan's assumption that hashing needed a worker to keep the page responsive. The DataChannel is the bottleneck.

256 KiB

the largest message the DataChannel accepts in Chromium, and the reason shards and frames are separate layers rather than one.

96 + 10

unit tests against a fake wire that can corrupt a byte or drop a shard, plus browser tests driving two real tabs through real WebRTC and OPFS.

0

servers that can read the file. On most networks none even sees it; when one must relay, it forwards packets it cannot decrypt, and the connection is labelled relayed.

The measurement was wrong first. The original harness reported zero stall everywhere — including for a deliberately blocking 300 ms loop, because a stall is only observable on the frame after it and the watcher stopped one frame too early. That control case is now published alongside the numbers, so they can be checked rather than believed.

What only production found

Every test passed, and then the app went onto real devices and real networks. Almost none of what broke was in the transfer protocol; nearly all of it was in the waiting around it.

The credentials that never arrived. Moving TURN credentials to the relay changed two functions, one per side of a connection. The edit to the joining side matched text a formatter had already rewritten, so it replaced nothing and raised no error. Only the offering device ever asked for credentials — and the joining device is usually the phone, the one behind carrier-grade NAT that needs them. Both sides now build their connection through one function so they cannot drift apart, and an end-to-end test counts /turn requests from each device. It was run against the old code first, to watch it fail.

The event that fired before anyone listened. On the joining side a browser can open the data channel before ondatachannel hands it over, more often on a slow phone. Its open event has then already fired, so the app waited for something that had happened and, twenty seconds later, reported a working connection as failed. Timing bugs like that reproduce only sometimes in a real browser, so the regression test uses a fake peer connection that puts the events in the failing order every time.

The pattern underneath both, and the lesson that stuck: absence is a state. The hardest bugs were messages never sent, events already fired, relays that never answered. Every wait on the network now has a deadline, and every deadline says what it was waiting for.

There is a great deal more

The full study covers the rest: why OPFS rather than IndexedDB, why a content id is a hash of the shard hashes, how folders became a loop instead of a new format, the complete table of bugs and which layer of testing caught each one, and what is deliberately not built.