跪拜 Guibai
← Back to the summary

How a Browser Pulls Off Peer-to-Peer File Transfers Without a File System or Listening Port

Open a web page, without installing any software, and transfer files directly to your phone without going through any server. This article explains how this is achieved in the browser, and clearly explains three things: WebRTC, WebTransport, and OPFS. The protagonist is SwarmDrop. The previous article was A Single Rust Core Running Tauri + React Native.

First, describe a scenario.

You are on your office computer and want to get dozens of photos you just took on your phone. WeChat transfer compresses them, cloud drives require login and throttle speed, AirDrop only works between Apple devices, and you don't have a data cable at hand—more importantly, this computer is not yours, and you are not comfortable installing any software on it.

The current answer is: Open a web page.

https://swarm-apps.github.io/SwarmDrop/app

The page itself is static, hosted on GitHub Pages. But once opened, this tab becomes a normal node in your device network—it can pair with a phone, receive files, and send files. Data travels through a peer-to-peer channel, and no server in the middle can access your plaintext.

First, an introduction to SwarmDrop

Summarized in one sentence: The experience of LocalSend, but not limited to the same local network.

Cross-Network Preserves Original File Requires Account Platform
WeChat / QQ Compresses images by default Required All platforms
Cloud Drive Required All platforms
AirDrop Proximity only Not required Apple only
LocalSend Same LAN only Not required All platforms
SwarmDrop Not required All platforms

Capability matrix of five file transfer tools across cross-network, original file, account, and platform dimensions

No account system, no central server, and no "upload—download" step. Two devices pair once by scanning a QR code, and thereafter recognize each other long-term: if on the same Wi-Fi, they use a direct LAN connection; if not on the same network, they automatically perform NAT hole-punching or relay forwarding, with the path automatically chosen by the program.

The entire transfer is end-to-end encrypted; relay nodes only handle ciphertext for which they have no key. Files have no size limit, no compression, no speed limit, and transfers can be resumed after interruption.

Currently, there are three endpoints:

Endpoint Status
Desktop macOS / Windows / Linux, all available for direct download
Mobile Android provides an installation package; iOS is limited by signing restrictions and currently can only be self-built
Browser Usable by opening the web page, no installation required (iOS users can use this directly)

Downloads are all on this page: https://swarm-apps.github.io/SwarmDrop/

Typical usage scenarios:

A brief mention of SwarmNote

Readers coming from the previous article might ask: What about that project?

I do not plan to continue maintaining SwarmNote.

The reason is simple: too many people are working in the notes space. From Obsidian, Logseq to various cloud notes, and new "local-first + end-to-end encrypted" solutions appearing every week, I find it hard to convince myself that the version I built could offer users something others cannot. Technically, it had no problems; everything that needed to run, ran. But technology working and a product being worth doing are two different things.

Personal energy is limited. Rather than having two projects stuck in a semi-finished state, it's better to make one truly usable.

However, the conclusion of the previous article has not been invalidated—quite the opposite. Precisely because the core and platform were clearly separated from the beginning, adding the browser endpoint this time did not require rewriting a single line of business logic: the transfer state machine, pairing protocol, and chunk verification run literally the same code in the browser as on the desktop.

The above covers the product and background. Now, let's get to the real subject of this article.

The previous article discussed how to make desktop and mobile share a single Rust core. This article discusses: How to bring the browser into the fold.

The reason this deserves its own article is that the browser itself does not possess the conditions to do this.


I. The Three Hands Tying the Browser Down

Desktop programs and mobile apps take three things for granted:

  1. Open a port and wait for someone to connect.
  2. Open a file, jump to byte 12345, and write a chunk of data.
  3. Decide on its own whether to use TCP or UDP, and which IP and port to connect to.

The browser cannot do any of these three things.

An analogy: Desktops and phones are like street-facing shops with independent addresses, anyone can find them; the browser is like an office in a building that can only exit through a revolving door—it can go out to meet people, but others cannot come in, and when going out, it can only take the few paths the security guard specifies.

So the question is not "how to write a Web interface," but:

How to make this "exit-only" office do business on equal footing with street-facing shops?

Among these three, "cannot listen" and "cannot open a socket" are two sides of the same coin and will be discussed together later. Thus, the main thread of the article consists of three parts:

Constraint Solution Corresponding Section
Cannot connect to another device WebRTC, and later supplemented by WebTransport II ~ V
No place to write files OPFS VI
One more invisible door secure context VII

The third item is not in the "three hands" list above because it is completely invisible before writing code—which is precisely why it deserves its own section.


II. Networking: The Browser Has Only Three Doors

The networking capabilities the browser provides to JavaScript are only three:

API What it can do Can connect to "another device"
fetch Send an HTTP request No, only servers
WebSocket Establish a persistent connection No, only servers
RTCPeerConnection Establish a peer-to-peer channel Yes

The first two share a common prerequisite: the peer must be a server with a domain name and a CA certificate. You cannot use fetch to connect to another personal computer.

Therefore, from the very first line of code, this endeavor is bound to the third option—WebRTC.


III. What Exactly Is WebRTC

This section is educational. If you've only heard of WebRTC in the context of Tencent Meeting or Google Meet, your impression of it is likely skewed.

The Misunderstanding Brought by the Name

WebRTC stands for Web Real-Time Communication. Because video calling is its most well-known application, many people think it is an "audio/video API."

In reality, audio/video is just one type of payload it incidentally supports. The real, more fundamental problem it solves is:

How can two machines, both behind routers and without public IP addresses, directly send data packets to each other?

This problem is called NAT traversal, and it is the very foundation of WebRTC.

Why Your Computer "Has No Address"

Devices behind a home router typically have IPs like 192.168.1.7. Hundreds of millions of devices worldwide use this same IP; it is meaningless on the public internet, and external parties cannot locate you through it.

You can access the internet because the router performs Network Address Translation (NAT): when you initiate an outbound connection, it temporarily assigns a "public IP + port" as your external identity, and return data packets are forwarded back to you based on this mapping.

The problem is that this mapping only works for parties you have actively contacted. If an external party sends a packet to your router out of the blue, the router has no corresponding mapping record and will simply discard it.

Thus, two devices both behind NAT trying to connect directly is like two people living in gated communities requiring keycards; neither can enter the other's gate.

How It Solves This: Four Layers

WebRTC is not a single API but a combination of an entire protocol stack. Broken down, there are four layers, each handling a part:

graph TB
    SDP["SDP —— Self-introduction<br/>What I support, my possible addresses, my certificate fingerprint"]
    ICE["ICE —— Pathfinding<br/>Pair all possible addresses and try them one by one"]
    DTLS["DTLS —— Encryption Handshake<br/>Perform TLS over UDP, exchange certificates and verify fingerprints"]
    SCTP["SCTP —— Multiplexing<br/>Carry multiple independent streams over a single encrypted channel"]
    DC["DataChannel —— The API actually used<br/>send() / onmessage"]
    SDP --> ICE --> DTLS --> SCTP --> DC

SDP is a self-introduction, plain text, containing things like "what codecs I support, what my possible addresses are, what my certificate fingerprint is." Only after both parties exchange this do they know how to communicate with each other.

ICE is responsible for pathfinding. The device first gathers a batch of "candidate addresses": its local LAN address, the public mapped address discovered via a STUN server, and relay addresses. After exchanging candidate lists, both parties pair them up and try them one by one, using the first pair that connects successfully.

The so-called "hole-punching" happens at this step, and the mechanism is not complex: both parties simultaneously send packets to each other's public mapped address. The packet sent by A is discarded at B's side, but it leaves a mapping record on A's own router; when B's packet arrives later, A's router sees that this address was just contacted and allows it through. When both sides do this simultaneously, the path is established.

DTLS is TLS running over UDP, responsible for encryption, while also verifying that "the peer indeed holds the certificate declared in the introduction."

SCTP implements multiplexing on top of the encrypted channel, allowing multiple independent, non-blocking streams. The DataChannel used in JS is one such SCTP stream.

Its Most Counter-Intuitive Point

WebRTC is not responsible for how the two parties exchange that "self-introduction."

ICE requires exchanging candidate addresses, and DTLS requires exchanging certificate fingerprints; all of this is written in the SDP. But the two machines cannot communicate before establishing a connection, so how is the SDP delivered?

WebRTC's answer is: This is not within its scope of responsibility. This "figure it out yourself" channel is called signaling.

Video conferencing software uses its own servers for signaling. SwarmDrop has no server; it uses already established relay connections: the browser first establishes a control channel through a public bootstrap node, and SDP and candidate addresses are passed through this channel; once the direct connection is established, data no longer passes through it.

This also conveniently explains a common confusion: Why do "serverless" P2P applications still actually need a machine on the public internet?

Because it doesn't touch the data, but it needs to help the two parties establish contact. These two things can be separated—the relay can only see ciphertext for which it has no key.

A Side Note: It's Not Easy to Use

The more thoroughly something is encapsulated, the more problems often leak from the bottom layer. We ultimately did not use an off-the-shelf implementation and wrote our own transport layer, submitting some patches to several upstream projects in the process.

Details are beyond the scope here, but I'll leave one general lesson:

In an asynchronous pipeline architecture, send() returning Ok does not mean the data has been sent.

We encountered a problem: send returned success, but the peer received no data, and neither side reported an error. After locating the issue, we found that the condition checked by send was not the same condition that actually determined success or failure, and the layer where the real failure occurred only logged a line of warn! before discarding the error.

An error that is warned away might be the caller's only chance to perceive a problem. This is especially important to note when writing library code.


IV. It Can Transfer, But It's Slow—Thus, a Second Door

The first version of the Web endpoint only had the WebRTC path, and it was functionally complete: pairing, sending, receiving, and resumable transfers all worked correctly.

But the problem surfaced when transferring large files.

On the same Wi-Fi, the phone and desktop could stably reach over ten MB/s, but the browser path was noticeably slower. The more obvious problem was unstable speed—same file, same network, smooth one time, then so slow the next that a retry was needed.

Later, a controlled test was done, turning subjective feelings into numbers: same machine, same upper-layer logic, only the transport layer replaced, transferring 64 MiB. WebRTC Direct's median was 72 MiB/s, with a fluctuation range of 43.7 to 288—a 6.6x difference between fastest and slowest.

The unstable speed was not an illusion.

Where the Slowness Comes From

The reason is not that WebRTC is poorly designed, but that it was never designed for this task.

Looking back at the four-layer diagram in Section III, beneath DataChannel is SCTP. This layer, in both the browser and native implementations, is a reliable transport implemented in software, running in user space. Fragmentation, reassembly, ordered delivery, and flow control must all be handled by it. And its optimization target is "small messages in real-time communication"—chat text, signaling, game state sync—pursuing low latency, not high throughput.

Add to this the elements necessary for NAT traversal: ICE probing, DTLS encryption/decryption. These overheads are negligible for small messages, but they all become apparent when trying to saturate bandwidth transferring a 2 GB file.

This is equivalent to using an off-road vehicle to haul cargo: it can indeed reach anywhere, but transport efficiency is not its design goal.

So the question becomes specific: Does another path exist in the browser, specifically for "connecting to a device at a known location, and doing so fast enough"?

Yes, and it only appeared in recent years—WebTransport.

First, QUIC

To understand WebTransport, one must first understand QUIC.

TCP has an inherent problem called head-of-line blocking: data on a single TCP connection must be delivered in order. When an earlier packet is lost, later packets that have already arrived must wait in line for retransmission. HTTP/2 multiplexes over a single TCP connection, resulting in a single stream's packet loss blocking all streams together—the problem was merely moved to a lower layer.

QUIC's approach is: Stop patching TCP; re-implement the transport layer directly on top of UDP.

It comes with built-in multiplexing (streams are truly independent, one stream's loss doesn't block others), built-in TLS 1.3 (handshake and encryption merged, 1-RTT or even 0-RTT connection establishment), and can maintain a connection without interruption when the network switches from Wi-Fi to 4G. HTTP/3 runs on top of QUIC.

WebTransport is the API that opens QUIC up for use by web page JavaScript.

Compared with WebSocket, the differences are clear:

WebSocket WebTransport
Underlying Transport TCP QUIC (UDP)
Multiplexing No, one stream per connection Yes, can open multiple independent streams
Head-of-line Blocking Yes No
Unreliable Transmission Not supported Supported (suitable for real-time audio/video)
Connect to machine with self-signed cert No Yes

The last row is the only one that truly matters for this project.

No Domain Name? How to Make the Browser Trust You

Browsers by default only trust certificates issued by CAs, and applying for a CA certificate requires a domain name.

But SwarmDrop needs to connect to the user's own computer, which has no domain name, only a 192.168.1.7.

The WebTransport specification leaves an exception mechanism for this, called serverCertificateHashes, which means:

Don't go through the CA system; only trust this one certificate, whose SHA-256 hash is xxxxx.

This hash is written directly into the address, in the following form:

/ip4/192.168.1.7/udp/4004/quic/webtransport/certhash/uEiDx.../p2p/12D3Koo...

This address not only specifies "where to connect" but also "which certificate to trust." The browser doesn't check with any CA; it only compares this hash.

This approach is common practice in browser environments—the WebRTC discussed earlier similarly relies on exchanging certificate fingerprints to establish trust.

It should be added: after the certificate verification passes, another identity handshake must still occur. Because the hash can only prove "the peer holds this certificate," it cannot prove "the peer is the target device." Device identity is a pair of Ed25519 keys and must be bound separately; otherwise, a man-in-the-middle could impersonate using their own certificate.

A Counter-Intuitive Rule: This Certificate Can Only Live for 14 Days

There is an unremarkable restriction in the specification: Self-signed certificates using serverCertificateHashes have a maximum validity of 14 days.

This restriction is easy to overlook initially, but it changes the nature of the whole endeavor.

Normally, a server certificate is "static configuration read once at startup." But rotating every 14 days means:

Therefore, the correct approach is: Always advertise two certificates simultaneously—the current one, and the next one.

This way, addresses held by clients remain valid across the rotation moment, at the cost of an address's actual lifespan becoming 28 days (two overlapping 14-day windows). Furthermore, the just-retired hash cannot be discarded immediately; it must be retained for a period to be compatible with addresses issued in the previous round.

This detail is worth noting because it illustrates something: The true complexity of a feature sometimes lies not in the main flow, but in the unexpected dimension it introduces. The dimension introduced here is "time"—something that originally had no lifecycle suddenly gained one.


V. Result: 4.5x Faster, But the Old Path Cannot Be Removed by an Inch

After integrating WebTransport, the same controlled test was re-run (same machine, 64 MiB, median of 6 runs, only transport layer replaced):

Transport Median Fluctuation Range
TCP 933 MiB/s 927–1149
WebTransport 322 MiB/s 286–326 (±7%)
QUIC 266 MiB/s 248–276
WebRTC Direct 72 MiB/s 43.7–288 (6.6x)

Two changes, each independent:

For user-perceptible experiences like "how much longer will this file take," the second point might be more important than the first—a stable 300 MiB/s is a noticeably better experience than one averaging similarly but fluctuating between 44 and 288. The speed instability mentioned earlier was precisely solved by this.

Now look at real device data. LAN, Android phone ↔ Desktop Chrome, single 2 GB file:

Direction Throughput
Phone → Browser ~20 MB/s (~160 Mbps)
Browser → Phone ~9 MB/s

The value of 20 already falls within the range of QUIC running between two native devices, meaning:

The browser is no longer the bottleneck in the receive direction.

This was an outcome not anticipated before starting.

As for why the two directions differ by more than double: when sending, the browser needs to read the file itself and compute checksums, and wasm is single-threaded without SIMD acceleration; this time cannot overlap with network writes. This part is still under optimization.

So, Can WebRTC Be Removed?

Despite the compelling numbers, the answer is no, and the reason has nothing to do with speed:

① NAT hole-punching is only available in WebRTC. WebTransport currently has no corresponding traversal mechanism. For cross-network scenarios like one party at home and another at the office, WebRTC is the only path.

② That 14-day certificate makes WebTransport unsuitable as the "first point of contact." The browser cannot hardcode an address that will expire; it needs to first connect to a bootstrap node via WebRTC, then obtain the currently valid WebTransport address from it.

Therefore, two entry points currently coexist, each with its own role:

flowchart LR
  B["Browser"]
  BS["Public Bootstrap Node"]
  N["Desktop / Phone"]
  B -->|"① WebRTC: Discovery + Relay"| BS
  BS -.->|"Inform of current valid address"| B
  B -->|"② WebTransport: Same-Network Fast Path"| N
  B -->|"③ WebRTC Hole-Punch: Only Cross-Network Option"| N

It should be noted: the above data was all measured in loopback and LAN environments; cross-network scenarios have not yet been tested; iOS Safari and Firefox have also not yet run the complete link.


VI. Storage: Where to Write Files

The second constraint beyond networking is: The browser has no file system.

To support resumable transfers, the core action is "open a file, jump to byte N, write a chunk, close," which belongs to file system semantics.

The browser has provided three generations of persistence facilities, differing in capability by an order of magnitude:

Facility Data Model Can Write by Position Suitable Scenario
localStorage String key-value pairs, ~5 MB No Small configs
IndexedDB Structured object key-value pairs (can store Blobs) No—can only read, modify, and write back as a whole Metadata, small objects
OPFS A complete file system (directories + files + handles) Yes Large files, random writes, resumable transfers

Many people's first instinct is IndexedDB; it can indeed store Blobs. But it has key-value semantics: modifying a single byte requires reading the entire value out, modifying it, and writing it back. Transferring a 4 GB file and rewriting 4 GB every time 256 KB is received is clearly infeasible.

What is OPFS

OPFS (Origin Private File System) is a branch of the File System Access API.

Each site (more precisely, each origin) can obtain a private, sandboxed file system root directory via navigator.storage.getDirectory(). Several key properties:

The last point is key—it makes browser-side resumable transfers possible. Close the tab mid-transfer, and it can continue next time it's opened.

The writing approach is roughly: open a write handle at the start of the transfer and keep it for the entire session; for each 256 KiB chunk received, write directly by offset, and commit at the end:

params.set_position(Some(offset as f64));   // Seek to this offset
params.set_data(&chunk);                    // Write this chunk
writable.write_with_write_params(&params)?;

Here's a counter-intuitive but important conclusion:

Large files don't fill up memory not because of a larger buffer, but because there is no buffer.

Receive a chunk, write it immediately, release immediately. At most, the size of one chunk is kept in memory simultaneously. Transferring 4 GB and transferring 4 MB use the same amount of memory. (The initial implementation accumulated into a large Blob before flushing to disk all at once; a few hundred MB would exhaust memory.)

Regarding whether continuous disk writing affects UI responsiveness: actual testing shows it does not. The reason is that disk write speed is far faster than the network; flushing to disk always keeps up with network progress and never backs up.

A Restriction Unique to the Browser

One more point worth noting: On the Web endpoint, only "receiving" can be resumed; "sending" cannot.

Files in the browser are File objects obtained via <input type="file">, and their lifecycle follows the page—after a refresh, the file can no longer be read unless the user re-selects it. Desktops and phones, on the other hand, receive a path; as long as the file remains, it can be reopened at any time.

Therefore, for sessions interrupted mid-send, we deliberately do not persist them. Restoring a session that cannot read its source file would only give the user a progress bar that can never advance.

For such places where "differences must exist due to fundamental platform constraints," my principle is to clearly document them and clearly not do them, rather than forcing an unusable implementation for the sake of symmetry across three endpoints.


VII. One More Invisible Door: secure context

This section is discussed separately because it applies to anyone doing persistence in the browser and can save a lot of debugging time.

The symptom was: accessing the Web endpoint via http://192.168.50.105:8080, the transfer process was smooth all the way—connection established, chunks pushed, verification all passed—except the final step of flushing to disk silently hung forever. No error, no timeout, no return.

Switching to access via http://127.0.0.1, the exact same code passed immediately, and the file was byte-for-byte identical.

The only variable was the page's address.

After two rounds of elimination in the code, I ran a line in the console:

console.log(isSecureContext, navigator.storage, crypto.subtle);
// false   undefined   undefined

The problem was immediately located.

What is a secure context

The browser has a global determination called secure context: whether the current page's origin is trustworthy enough to grant access to a set of sensitive APIs.

The whitelist is as follows:

Criterion Example Is Secure
https: https://swarmdrop.app
Loopback address http://127.0.0.1
localhost http://localhost:1420
http + private IP http://192.168.50.105
http + domain name http://example.com

The most counter-intuitive part is the middle comparison: 127.0.0.1 over http is a secure context; 192.168.50.105 over http is not. "Local machine" and "LAN" belong to two different worlds here.

And when the condition is not met, the APIs restricted by this do not throw an exception, but the entire property does not exist:

Thus, the call lands on undefined, and the Promise hangs forever—the hardest form of failure to debug, with neither error nor timeout.

Why did only flushing to disk fail, while networking was completely normal?

This is the most concealed part of the root cause.

Our encryption handshake uses a self-contained pure Rust implementation, completely independent of crypto.subtle. Therefore, the lack of a secure context had no impact on encryption and connection establishment; the network layer was entirely normal.

The result was "networking fully normal, only storage hanging," a symptom most easily misdiagnosed as a transfer bug.

Three reusable lessons:

① Production web pages must use https. Do not directly host an HTTP service on a LAN to serve the page; that way, OPFS and Web Crypto will disappear together. The correct approach is the page loads from https, and only the P2P connections point to LAN IPs.

② Code calling platform APIs should check before calling and set a timeout for every await. A Promise landing on undefined must not be allowed to hang forever.

③ When encountering "a path related to some platform API mysteriously hangs," first check the platform status in the console, then go back to your own code to investigate. One line of console.log can save two rounds of debugging.


VIII. What It Looks Like When the Three Ends Come Together

Putting the previous sections together:

flowchart TB
  subgraph UI[UI Layer]
    D[Desktop<br/>React + Tauri]
    M[Mobile<br/>Expo + React Native]
    W[Browser<br/>Next.js]
  end

  subgraph HOST[Platform Adaptation Layer]
    DH[Tauri IPC]
    MH[UniFFI Bridge]
    WH[wasm-bindgen]
  end

  subgraph CORE[Shared Rust Core]
    C["Identity · Pairing · Device Management"]
    T["Transfer Session · Resume · Chunk Verification"]
    N["Network Kernel · Protocol Routing · Connection Management"]
  end

  D --> DH --> C
  M --> MH --> C
  W --> WH --> C
  C --> T
  C --> N
  T --> N

To what extent is the "same core" the same?

It's not "all code is completely identical," but rather: the transfer state machine, protocols, chunk verification, and device identity parts are not duplicated per platform; the I/O that truly must differ is converged within their respective interface implementations.

A concrete example: the interface method write_sink_chunk(file, offset, data). The desktop implementation is std::fs seek + write; the browser implementation is the OPFS write described above. The upper-layer transfer logic has no idea which platform it's running on.

Thus, from the user's perspective, sending a file from the browser is still just two steps: "select file, select device":

flowchart TD
  U["Select File + Select Paired Device"]
  W["Transfer Core in wasm"]
  D{"Determine Target Address and Network Conditions"}
  R1["Same Network, Address Known → WebTransport"]
  R2["Cross-Network → WebRTC Hole-Punch"]
  R3["Neither Reachable → Relay Forwarding"]
  X["Chunking · Chunk Verification · Progress · Resume"]
  N["Visible Directory on Peer Device"]
  U --> W --> D
  D --> R1 --> X
  D --> R2 --> X
  D --> R3 --> X
  X --> N

What the peer desktop receives is the same transfer session, not a parallel implementation of "web uploads to server, native downloads from server."


IX. Current Progress

All three endpoints are running, current version v0.21.0.

The capabilities listed at the beginning—cross-network direct connection, end-to-end encryption, QR code pairing, resumable transfers, AI callable—have all been implemented and have been running stably for some time. On the feature level, I consider it complete.

The current focus is entirely on polishing: aligning interactions across the three endpoints, interface consistency, copy for edge states (what should you tell the user when a connection fails that is actually useful), and convergence of real-device links, especially in cross-network scenarios.

This distance is the gap between "usable" and "pleasant to use," and it is often longer than implementing the features themselves.

After stopping maintenance on SwarmNote, my time has been almost entirely on this one project.

The current stage is when feedback is most valuable—features are stable, but I really want to hear what feels awkward, what's hard to understand, which flow is convoluted. Welcome to raise them in the Issues section, or in the comments.


Finally, back to the question at the beginning: How can a browser transfer files peer-to-peer?

The answer is three things: WebRTC allows it to reach another device, WebTransport makes that path fast enough, and OPFS gives it a place to write to disk.

After connecting these three, you'll find that desktop, phone, and browser are just three entry points. What truly should not change is: when a file goes from one device to another, the protocol, identity, encryption, and integrity verification do not become three separate implementations just because "today I opened a different endpoint."

References