How a Browser Tab Sends a File Bigger Than Its Own Memory
A tab gets a few gigabytes of RAM at best. Moving a 100 GB file through one means never holding it — on either side. Here are the four problems that creates, and the numbers we settled on for each.
Why the obvious version fails
The shortest code that appears to send a file over a WebRTC data channel reads the file, then sends
it. Both halves of that are fatal at size. Reading a 100 GB file into an ArrayBuffer
needs 100 GB of RAM the tab does not have, and even if it did, handing 100 GB to a single
send() call would fail long before the network was involved.
So the file has to be cut into pieces, and the pieces have to be paced. Neither of those is difficult in isolation. What makes this interesting is that each one has a limit set by something other than your code — the SCTP transport, the browser's buffer accounting, the operating system's willingness to keep a service worker alive — and the failure modes are quiet.
Problem one: chunk size, and the 64 KB trap
We send in 256 KB chunks. Bigger chunks mean fewer messages and less per-message overhead, so throughput improves as you raise it — up to a hard limit that is easy to miss in testing.
A WebRTC data channel runs over SCTP, and RTCSctpTransport.maxMessageSize tells you the
largest message the remote peer will accept. Chrome talking to Chrome negotiates a large value and
256 KB sails through. But when the remote peer's SDP omits the max-message-size
attribute, the specification default applies and the limit collapses to 64 KB.
Exceeding it does not truncate the message or slow things down. send() throws a
TypeError, which aborts the entire transfer. A 40 GB file that has been running for
three hours dies on a message the transport was never going to accept.
The fix is to read the negotiated limit rather than assume one, and to clamp to the smallest limit across every peer you are sending to:
const mms = pc.sctp && pc.sctp.maxMessageSize;
// clamp to the smallest peer limit, floor at 16 KiB
return Math.max(16384, Math.min(CHUNK_SIZE, limit));
Two details that cost us time. pc.sctp does not exist until negotiation completes, so
this has to be read after the channel opens rather than when it is created. And the 16 KiB floor is
deliberate: it is the size every browser reliably accepts on an ordered, reliable channel, so it is
the value to fall back to when the negotiated limit is missing or implausible.
Problem two: backpressure, or filling your own memory
Chunking alone does not save you. A loop that reads and sends without pausing will queue chunks into the data channel's outbound buffer faster than the network drains them — and that buffer is memory in your tab. You have replaced "load the whole file into RAM" with "load most of the file into RAM slightly later".
The control is bufferedAmount: how many bytes are queued and not yet sent. The standard
pattern is a high-water mark to stop at and a low-water mark to resume at. Ours are
4 MB and 1 MB.
The low-water mark is where the interesting constraint lives, and it is not a number you can reason
your way to from the specification. Chromium only updates the JavaScript-visible
bufferedAmount when it has dropped by at least 100 KiB — an
internal constant called kMinBufferedAmountDiffToTriggerCallback. Set your low-water
mark near that figure and the bufferedamountlow event fires late and coarsely, so the
send loop stalls waiting for a signal that arrives after the pipe has already drained. Throughput
collapses for a reason nothing in your code explains.
1 MB sits comfortably above that granularity and keeps roughly four 256 KB chunks in flight, which is enough to hold the pipe full while the loop wakes up. If you are tuning this yourself, that ratio matters more than either absolute number: the low-water mark needs to leave enough queued to cover the round trip of noticing and refilling.
Problem three: the receiving side has the same problem, backwards
It is tempting to think of receiving as easier. It is not, and it is where large transfers actually
fail in practice. The naive receiver accumulates chunks in an array and calls new Blob()
at the end — which means the whole file passes through the tab's memory. It works beautifully
for a 200 MB test file and dies on a 20 GB one.
The escape is a service worker. Rather than assembling the file in the page, the page streams chunks
to a service worker that answers a download request with a ReadableStream. The browser
writes to disk as data arrives, exactly as it would for an ordinary download from a server, and the
tab never holds more than the stream's buffer.
That buffer needs setting explicitly, and the default is a trap worth knowing. A stream's default
queuing strategy has a highWaterMark of one chunk — counted in
chunks, not bytes. For 256 KB binary chunks that makes desiredSize almost meaningless
as a memory signal. We buffer by bytes instead, with a 16 MB ceiling, so the number
describes actual memory rather than an item count.
The bug that taught us the most
A service worker is not a process you control. The browser may terminate an idle one at any moment, and Android is markedly more aggressive about it than desktop.
Our service worker held its active downloads in an in-memory Map, keyed by file id. When
the worker was killed mid-transfer, the browser restarted it with an empty Map. Every
subsequent chunk then looked up an id that was no longer there, hit an if (download)
guard, and was silently discarded.
The page had no idea. It kept counting bytes, kept drawing the progress bar, and reported a completed transfer. The file on disk was short. A truncated video still plays for a while before stopping; a truncated archive fails to open with an error that blames the archive.
The lesson generalises past service workers: a lookup miss on a path that only ever runs during normal operation is not a no-op, it is data loss. Every miss now reports an error back to the page and fails the transfer loudly. A failed transfer someone can retry is enormously better than a corrupt file they discover next week.
What this means if you are just sending a file
None of the above requires anything from you. But the constraints explain a few behaviours worth knowing:
- The receiving device needs free disk space, not free RAM. Because the download streams to disk, a modest laptop can receive a file far larger than its memory. What it cannot do is receive a file larger than its free storage.
- Both tabs must stay open. There is no server holding a partial copy, so an interrupted transfer restarts rather than resuming. Worth checking your sleep settings before starting something that will run for hours.
- Progress is honest. After the truncation bug above, the byte count reflects data the receiver confirmed it wrote. If something goes wrong you get an error, not a green tick and a broken file.
- Speed is set by your upload, not by chunk size. All the tuning here removes ceilings; it does not add capacity. What you are left with is your own connection — which we go through in how fast a browser-to-browser transfer really is.
If what you actually need is the practical version — what to do when email bounces your attachment and the cloud free tier says no — sending large files without limits covers the options and their trade-offs without the transport-layer detail.