DEV Community

Anirban Roy
Anirban Roy

Posted on

WebRTC Data Channels for Large File Transfer: What I Learned the Hard Way

WebRTC looks deceptively simple when you first use it for file transfer.

Create a peer connection.

Open a data channel.

Read a file.

Send the bytes.

Done.

That approach works beautifully in a demo.

Then you try a 20GB file.

Then 50GB.

Then 100GB.

Then Safari decides your tab has had enough.

Then the receiver runs out of memory.

Then the sender's bufferedAmount keeps climbing.

Then the connection drops at 93%.

Then you realize the hard part was never getting bytes from one browser to another.

The hard part is doing it reliably, for a long time, across real browsers, on real networks, without blowing up memory or making the user restart from zero.

That is what I learned while building large-file transfer into ButterShare.

This post is not about a toy WebRTC example.

It is about the parts that start to matter when your transfer is no longer 5MB.


WebRTC Data Channels are powerful, but they are not a file transfer protocol

That was the first important lesson.

A WebRTC data channel gives you a way to move arbitrary data between peers.

That is all.

It does not automatically give you:

  • file metadata
  • chunking
  • progress
  • backpressure
  • retry logic
  • resume support
  • integrity checks
  • folder structure
  • acknowledgements
  • transfer state
  • error recovery

If you need those things, you have to design them.

The browser gives you transport.

You still need to build the protocol.

That difference becomes very important once transfers get large.


The naive implementation

The obvious first version looks something like this:

const buffer = await file.arrayBuffer()
dataChannel.send(buffer)
Enter fullscreen mode Exit fullscreen mode

For a tiny file, this is fine.

For a large file, it is a disaster.

If the file is 10GB, you absolutely do not want to read 10GB into memory.

And you definitely do not want to do this on a phone.

The better mental model is:

Never think of a large file as one object.

Think of it as a sequence of chunks.


Chunking is not optional

The basic idea is simple.

Instead of this:

[ 100GB file ]
Enter fullscreen mode Exit fullscreen mode

you treat it as:

[chunk 1]
[chunk 2]
[chunk 3]
[chunk 4]
...
Enter fullscreen mode Exit fullscreen mode

In JavaScript:

const CHUNK_SIZE = 64 * 1024

let offset = 0

while (offset < file.size) {
  const chunk = file.slice(offset, offset + CHUNK_SIZE)
  const buffer = await chunk.arrayBuffer()

  dataChannel.send(buffer)

  offset += CHUNK_SIZE
}
Enter fullscreen mode Exit fullscreen mode

This is already much better.

You only keep a small part of the file in memory at a time.

But this still breaks under pressure.

Why?

Because you're sending chunks faster than the network can necessarily transmit them.

That leads to the next problem.


bufferedAmount will humble you

WebRTC data channels have an internal outgoing buffer.

You can inspect it with:

dataChannel.bufferedAmount
Enter fullscreen mode Exit fullscreen mode

If you keep doing this:

dataChannel.send(chunk)
Enter fullscreen mode Exit fullscreen mode

inside a fast loop, you can fill that buffer faster than the browser can drain it.

At first, everything looks normal.

Then memory starts climbing.

Then the tab becomes sluggish.

Then performance collapses.

Then the browser may kill the page.

This is one of the first things that separates a demo from a serious file-transfer implementation.


Backpressure matters more than chunk size

A common mistake is focusing too much on finding the "perfect" chunk size.

Should it be:

16KB?
64KB?
256KB?
1MB?
Enter fullscreen mode Exit fullscreen mode

Chunk size matters, but flow control matters more.

You need to slow down when the browser's send buffer gets too large.

For example:

const MAX_BUFFERED = 4 * 1024 * 1024

async function waitForBuffer() {
  while (dataChannel.bufferedAmount > MAX_BUFFERED) {
    await new Promise(resolve => setTimeout(resolve, 10))
  }
}
Enter fullscreen mode Exit fullscreen mode

Then:

while (offset < file.size) {
  await waitForBuffer()

  const chunk = file.slice(offset, offset + CHUNK_SIZE)
  const buffer = await chunk.arrayBuffer()

  dataChannel.send(buffer)

  offset += CHUNK_SIZE
}
Enter fullscreen mode Exit fullscreen mode

That works better.

But polling every few milliseconds is still not ideal.

WebRTC gives you a cleaner option.


Use bufferedAmountLowThreshold

You can tell the browser:

Let me know when your send buffer has dropped below this level.

For example:

dataChannel.bufferedAmountLowThreshold = 1024 * 1024
Enter fullscreen mode Exit fullscreen mode

Then:

function waitUntilWritable(channel) {
  if (channel.bufferedAmount <= channel.bufferedAmountLowThreshold) {
    return Promise.resolve()
  }

  return new Promise(resolve => {
    const handler = () => {
      channel.removeEventListener("bufferedamountlow", handler)
      resolve()
    }

    channel.addEventListener("bufferedamountlow", handler)
  })
}
Enter fullscreen mode Exit fullscreen mode

Now the sender can wait until the channel is ready for more data.

This is far more stable than dumping chunks into the buffer as fast as possible.


Large file transfer is a flow-control problem

Once files get big, your job is not:

send as fast as possible

It is:

send as fast as the browser and network can safely sustain

That distinction matters.

If you push too aggressively, you get:

  • memory pressure
  • unstable throughput
  • browser crashes
  • poor mobile behavior

If you throttle too much, you leave bandwidth unused.

So you are constantly balancing:

speed
vs
stability
Enter fullscreen mode Exit fullscreen mode

And for 100GB transfers, stability wins.

A slightly slower transfer that finishes is better than a faster transfer that dies after 70GB.


Metadata has to come first

The receiver needs context before binary chunks arrive.

At minimum, I want the receiver to know things like:

{
  "type": "file-start",
  "id": "file-001",
  "name": "final-cut.mov",
  "size": 21474836480,
  "mimeType": "video/quicktime"
}
Enter fullscreen mode Exit fullscreen mode

Then the actual file data can follow.

For multiple files:

{
  "type": "transfer-start",
  "files": [
    {
      "id": "file-001",
      "name": "video.mov",
      "size": 21474836480
    },
    {
      "id": "file-002",
      "name": "thumbnail.jpg",
      "size": 5278321
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Now the receiver knows what to expect.

This sounds trivial.

It becomes very important for:

  • progress calculation
  • folder reconstruction
  • resume
  • error reporting
  • multi-file transfers

I stopped thinking in messages and started thinking in protocol states

At first, you can get away with random messages like:

"start"
"chunk"
"done"
Enter fullscreen mode Exit fullscreen mode

That gets messy very quickly.

A proper protocol is easier to reason about.

For example:

SESSION_READY
TRANSFER_START
FILE_START
FILE_CHUNK
FILE_PROGRESS
FILE_END
TRANSFER_END
ACK
RETRY
RESUME_REQUEST
ERROR
Enter fullscreen mode Exit fullscreen mode

Once you have that, the system becomes much easier to debug.

You can actually answer:

What state is the receiver in?

instead of:

Why is this boolean false?


The receiver is where many implementations fall apart

Sending chunks safely is only half of the problem.

A lot of examples do this on the receiver:

const chunks = []

channel.onmessage = event => {
  chunks.push(event.data)
}
Enter fullscreen mode Exit fullscreen mode

Then:

const blob = new Blob(chunks)
Enter fullscreen mode Exit fullscreen mode

This is fine for small files.

It is completely wrong for huge files.

If you're receiving 100GB and storing every chunk in memory, you are effectively trying to build a 100GB array inside the browser.

That is not going to end well.

The receiver needs progressive writing.


Progressive writing is where browser differences become painful

On supported browsers, APIs such as the File System Access API can let you write incrementally.

Conceptually:

const handle = await window.showSaveFilePicker({
  suggestedName: fileName
})

const writable = await handle.createWritable()
Enter fullscreen mode Exit fullscreen mode

Then as chunks arrive:

await writable.write(chunk)
Enter fullscreen mode Exit fullscreen mode

And finally:

await writable.close()
Enter fullscreen mode Exit fullscreen mode

This is the right model.

Data arrives.

Data is written.

Memory stays bounded.

But browser support is not uniform.

That is where the product challenge starts.

Chrome on desktop may give you capabilities that Safari on iPhone does not expose in the same way.

So you cannot build your architecture assuming one browser API is universally available.


"Works in Chrome" means almost nothing

One of the biggest mistakes in browser networking is testing only in Chrome desktop.

A real file-transfer product needs to survive:

  • Chrome
  • Edge
  • Firefox
  • Safari
  • iOS Safari
  • Android Chrome
  • low-memory devices
  • aggressive mobile OS background behavior

And each one behaves differently under long transfers.

Desktop WebRTC is the easy part.

Mobile is where the real problems show up.


Mobile browsers are brutal

A 30-minute transfer on desktop might be fine.

On a phone, many things can interrupt you:

  • screen locking
  • switching apps
  • battery saver
  • background tab throttling
  • memory pressure
  • network handoff
  • Wi-Fi to mobile-data switching
  • browser suspension

The user does not care why.

They just see:

Transfer failed.

That means your UX needs to prepare them.

For example:

Keep this tab open until the transfer finishes.
Enter fullscreen mode Exit fullscreen mode

Or:

Avoid locking your phone during large transfers.
Enter fullscreen mode Exit fullscreen mode

It may not feel elegant, but it is better than pretending the browser has infinite control over the operating system.


Connection setup is not the same as file transfer

WebRTC needs signaling before the peers can communicate.

Usually that means exchanging:

  • SDP offer
  • SDP answer
  • ICE candidates

Your signaling server helps both sides find each other.

Something like:

Sender
  |
  | Offer
  v
Signaling server
  |
  | Forward
  v
Receiver
Enter fullscreen mode Exit fullscreen mode

Then:

Receiver
  |
  | Answer
  v
Signaling server
  |
  | Forward
  v
Sender
Enter fullscreen mode Exit fullscreen mode

ICE candidates are exchanged too.

Once the peer connection is established, the file data can flow.

The key architectural point is this:

The signaling server coordinates the connection. It does not need to store the file.

That keeps the control plane separate from the data plane.


STUN and TURN are where the clean P2P story gets messy

In the ideal case:

Browser A <---------> Browser B
Enter fullscreen mode Exit fullscreen mode

Direct connection.

Nice.

But users are behind:

  • routers
  • NAT
  • carrier-grade NAT
  • enterprise firewalls
  • VPNs
  • restrictive mobile networks

So WebRTC uses ICE.

STUN helps peers discover network information.

TURN is used when a direct route cannot be established.

Then you get:

Browser A
    |
    v
TURN server
    |
    v
Browser B
Enter fullscreen mode Exit fullscreen mode

That transfer is still not being stored like a normal cloud-hosted file.

But the traffic is relayed.

And that matters financially.

If a 100GB transfer goes through TURN, you are now relaying 100GB.

Do that often enough and your infrastructure bill becomes very real.


P2P does not mean zero infrastructure cost

This is a misconception I see a lot.

You still need:

  • signaling
  • TURN
  • STUN
  • session management
  • monitoring
  • abuse prevention
  • analytics
  • connection diagnostics

And TURN bandwidth can be expensive.

A useful product needs to track:

direct connection rate
relay rate
average file size
average transfer duration
failure rate
resume rate
browser distribution
Enter fullscreen mode Exit fullscreen mode

Otherwise, you are building blind.


Acknowledgements make progress more honest

Another thing I learned:

Do not call send() and immediately assume those bytes reached the receiver.

This:

dataChannel.send(chunk)
bytesSent += chunk.byteLength
Enter fullscreen mode Exit fullscreen mode

only tells you that the browser accepted the data into its sending pipeline.

It does not necessarily mean the receiver has written it.

For a better progress model, the receiver can periodically acknowledge bytes received.

Example:

{
  "type": "ack",
  "fileId": "file-001",
  "receivedBytes": 5368709120
}
Enter fullscreen mode Exit fullscreen mode

Now the sender knows:

5GB confirmed on receiver
Enter fullscreen mode Exit fullscreen mode

That is much more meaningful than:

5GB queued locally
Enter fullscreen mode Exit fullscreen mode

Don't acknowledge every chunk

That sounds tempting.

But if you send an ACK for every 64KB chunk, you create unnecessary protocol chatter.

Instead, acknowledge periodically.

For example:

every 4MB
every 16MB
every 100 chunks
every N milliseconds
Enter fullscreen mode Exit fullscreen mode

The exact strategy depends on your implementation.

You want enough acknowledgement data to support:

  • accurate progress
  • resume
  • fault recovery

without turning the protocol into an ACK storm.


Resume support changes everything

If you're transferring 500MB, restarting is annoying.

If you're transferring 100GB, restarting is unacceptable.

Imagine this:

Transferred: 96.7GB
Connection lost.
Start again?
Enter fullscreen mode Exit fullscreen mode

Nobody wants that.

Resume support means both sides need to agree on what has already been completed.

For example:

{
  "type": "resume-state",
  "fileId": "file-001",
  "receivedBytes": 103809024000
}
Enter fullscreen mode Exit fullscreen mode

Then the sender resumes from that offset.

Conceptually:

const remaining = file.slice(receivedBytes)
Enter fullscreen mode Exit fullscreen mode

Of course, a production system needs more checks than this.

You need to ensure:

  • same file
  • same session
  • same size
  • correct offset
  • receiver state is valid

But the concept is straightforward.

Resume is not a nice extra for huge files.

It is part of the core product.


File identity matters for resume

You cannot blindly trust:

file name
Enter fullscreen mode Exit fullscreen mode

Two files can have the same name.

So a real resume mechanism should use better identity information.

Possibilities include:

  • file size
  • last-modified timestamp
  • transfer-generated ID
  • checksum
  • chunk hashes

For example:

{
  "id": "transfer-file-8f73",
  "name": "footage.mov",
  "size": 82471843840,
  "lastModified": 1788402394000
}
Enter fullscreen mode Exit fullscreen mode

For stronger verification, add a digest.

The exact level depends on your threat model and performance requirements.


Hashing huge files has a cost too

Hashing a 100GB file before transfer sounds great.

Until the user waits while you read the entire 100GB once just to calculate the hash.

Then they wait again while you actually send it.

That may be unacceptable.

A better strategy can be incremental.

For example:

  • hash while transferring
  • hash chunks
  • hash groups of chunks
  • calculate a rolling digest

This gives you integrity verification without requiring a full extra pass before transfer.


Large file transfer makes small bugs expensive

A 0.1% edge case sounds tiny.

At scale, it is not.

And for a user in the middle of a long transfer, that one bug can waste an hour.

With huge transfers, things that seem minor suddenly matter:

  • duplicate chunks
  • missing chunks
  • out-of-order logic
  • reconnect races
  • incorrect offsets
  • final partial chunk handling
  • file-close timing
  • stale session state

A 20KB mistake at the end of a 100GB file is still a corrupted file.


Ordered vs unordered data channels

WebRTC data channels can be configured in different ways.

The default is ordered delivery.

For file transfer, ordered delivery is usually the simplest choice because file chunks naturally belong in sequence.

For example:

const channel = peerConnection.createDataChannel("file-transfer", {
  ordered: true
})
Enter fullscreen mode Exit fullscreen mode

Unordered delivery can be useful for other real-time use cases.

For a file-transfer system, ordered delivery generally makes reconstruction easier.

That said, your application-level protocol should still know which chunk or byte range it is dealing with.

Do not rely entirely on transport behavior as your only source of truth.


Reliable vs partially reliable delivery

WebRTC lets you configure data channels with options such as:

maxRetransmits
Enter fullscreen mode Exit fullscreen mode

or:

maxPacketLifeTime
Enter fullscreen mode Exit fullscreen mode

Those are useful for real-time data where dropping an old packet can be better than waiting.

Examples:

  • games
  • live telemetry
  • fast-changing state

File transfer is different.

If a chunk disappears, the file becomes corrupt.

For file transfer, reliability usually matters more than latency.

You do not want:

99.999% of the file
Enter fullscreen mode Exit fullscreen mode

You want:

100%
Enter fullscreen mode Exit fullscreen mode

Chunk size is a tradeoff

There is no universal best chunk size.

Small chunks:

Pros

  • lower memory pressure
  • more granular progress
  • easier retry boundaries

Cons

  • more application overhead
  • more messages
  • more protocol bookkeeping

Large chunks:

Pros

  • less message overhead
  • potentially better throughput

Cons

  • larger temporary memory
  • less granular resume
  • more painful retries

For many implementations, something in the tens of kilobytes is a reasonable place to start.

But benchmark on your target browsers.

Do not copy a chunk size from a blog post and assume it is correct for your product.

Including this one.


Throughput is not just network bandwidth

Let's say the sender has:

1 Gbps connection
Enter fullscreen mode Exit fullscreen mode

That does not mean you will get a 1 Gbps browser transfer.

Other bottlenecks may include:

  • Wi-Fi
  • CPU
  • encryption overhead
  • JavaScript execution
  • disk read speed
  • disk write speed
  • browser implementation
  • TURN path
  • packet loss
  • mobile hardware

You need to measure the real end-to-end pipeline.

A file transfer is roughly:

disk read
   ↓
JavaScript
   ↓
WebRTC buffer
   ↓
network
   ↓
WebRTC receiver
   ↓
JavaScript
   ↓
disk write
Enter fullscreen mode Exit fullscreen mode

The slowest part wins.


Local network transfers can be extremely fast

One thing that makes peer-to-peer transfer interesting is that nearby devices may be able to communicate efficiently over the local network.

If two capable machines are on fast Wi-Fi or Ethernet, local transfers can perform very well.

But again, there are no guarantees.

Network topology matters.

Browsers matter.

Routing matters.

You should never hardcode claims like:

always gigabit speed
Enter fullscreen mode Exit fullscreen mode

unless you can actually guarantee that.

You usually cannot.

A better claim is:

Performance depends on the connection between the devices.

Less exciting.

More accurate.


Error messages matter

Developers love errors like:

ICE connection state failed
Enter fullscreen mode Exit fullscreen mode

Users do not.

They need:

Could not establish a direct connection. Retrying...
Enter fullscreen mode Exit fullscreen mode

Or:

The transfer was interrupted. Reconnecting from 42.8GB...
Enter fullscreen mode Exit fullscreen mode

Or:

Your device went offline. Check your connection and try again.
Enter fullscreen mode Exit fullscreen mode

A good transfer product converts networking details into understandable states.


Connection state is more nuanced than connected/disconnected

You will see states like:

new
checking
connected
completed
disconnected
failed
closed
Enter fullscreen mode Exit fullscreen mode

And disconnected does not always mean permanently dead.

A network switch may briefly cause disconnects.

That means your app should not immediately destroy the session when the connection flickers.

You need grace periods.

Retries.

State transitions.

This is why I eventually stopped using simple flags and started thinking in terms of a state machine.


A transfer state machine is worth it

Something like:

IDLE
  ↓
CREATING_ROOM
  ↓
WAITING_FOR_PEER
  ↓
CONNECTING
  ↓
READY
  ↓
TRANSFERRING
  ↓
COMPLETED
Enter fullscreen mode Exit fullscreen mode

With side paths:

TRANSFERRING
  ↓
DISCONNECTED
  ↓
RECONNECTING
  ↓
RESUMING
  ↓
TRANSFERRING
Enter fullscreen mode Exit fullscreen mode

And failure:

RECONNECTING
  ↓
FAILED
Enter fullscreen mode Exit fullscreen mode

This structure makes your UI, analytics, and error recovery much cleaner.

It is much better than managing:

isSending
isConnected
isReconnecting
isDone
hasError
Enter fullscreen mode Exit fullscreen mode

and hoping every combination makes sense.


Don't tie your protocol directly to React state

This one is more architectural.

Your transfer engine should not exist as a pile of React state and hooks.

The transfer process can live for:

minutes
hours
Enter fullscreen mode Exit fullscreen mode

It deserves its own abstraction.

I prefer separating:

UI
Protocol
Transport
File IO
Session state
Enter fullscreen mode Exit fullscreen mode

For example:

React UI
   ↓
Transfer Controller
   ↓
Transfer Protocol
   ↓
WebRTC Transport
Enter fullscreen mode Exit fullscreen mode

That makes debugging much easier.

It also means the networking logic is not constantly being rewritten because the UI changed.


Multiple files are harder than one huge file

A single 100GB file has one nice property:

It is one continuous byte range.

A folder containing 20,000 files is more complicated.

Now you need:

  • file boundaries
  • metadata
  • directory structure
  • relative paths
  • empty directories
  • duplicate names
  • skipped files
  • per-file progress

The protocol becomes something like:

TRANSFER_START

FILE_START
CHUNKS...
FILE_END

FILE_START
CHUNKS...
FILE_END

...

TRANSFER_END
Enter fullscreen mode Exit fullscreen mode

For folders, you also need path information.

Example:

{
  "type": "file-start",
  "name": "hero.png",
  "relativePath": "project/assets/images/hero.png",
  "size": 834021
}
Enter fullscreen mode Exit fullscreen mode

Now the receiver can reconstruct the structure.


Sending 20,000 tiny files can be worse than one huge file

This is another surprise.

A 50GB single video may be easier to handle than:

50GB spread across 100,000 tiny files
Enter fullscreen mode Exit fullscreen mode

Why?

Because every file adds overhead:

  • metadata
  • open/close operations
  • filesystem work
  • protocol messages
  • progress updates

This is why bundling directories can sometimes make sense.

But then you have tradeoffs around:

  • ZIP creation time
  • memory
  • browser support
  • streaming archives

Nothing is free.


The browser is capable, but not unlimited

I love building serious applications in the browser.

But it is important to respect the environment.

A browser tab is not a native daemon.

It can be:

  • suspended
  • throttled
  • killed
  • reloaded
  • closed accidentally

Your design needs to assume imperfect conditions.

That means:

  • save session state where reasonable
  • support reconnection
  • avoid huge in-memory structures
  • communicate clearly with the user
  • test long-running transfers

Test duration, not just file size

This is a subtle one.

You may test a 50GB transfer on a fast LAN and finish quickly.

Then assume 50GB works.

But a remote user with slow upload might take several hours.

That means your system also needs to survive:

long duration
Enter fullscreen mode Exit fullscreen mode

not just:

large byte count
Enter fullscreen mode Exit fullscreen mode

Those are different stress tests.

I would test scenarios like:

5GB over fast LAN
50GB over fast LAN
5GB over slow internet
50GB over throttled internet
2-hour transfer
4-hour transfer
mobile sender
mobile receiver
network interruption mid-transfer
Enter fullscreen mode Exit fullscreen mode

You learn different things from each one.


Simulate bad networks

Do not test only on your office Wi-Fi.

Large-transfer software needs to survive:

  • latency
  • packet loss
  • slow uploads
  • unstable Wi-Fi
  • connection changes

Use network throttling where possible.

Switch networks manually.

Turn Wi-Fi off temporarily.

Lock the phone.

Background the browser.

Try the things real users will accidentally do.

The bugs you find this way are usually much more valuable than another perfect localhost test.


TURN fallback needs real-world testing

It is easy to say:

TURN works.

You need to test:

  • different NAT types
  • corporate networks
  • VPNs
  • mobile carriers
  • IPv4
  • IPv6

And then monitor how often TURN is actually used.

If your product starts growing and 40% of huge transfers are going through TURN, your cost model looks very different from what you expected.


Security is more than encrypted transport

WebRTC uses encrypted transport, which is great.

But your application still needs to think about:

  • room guessing
  • unauthorized joining
  • expired sessions
  • signaling authentication
  • rate limiting
  • replay
  • transfer links
  • metadata exposure

If your room code is:

ABC123
Enter fullscreen mode Exit fullscreen mode

how hard is it to guess?

How many attempts can someone make?

How long does that room exist?

Does the share URL include additional entropy?

Security is a system property.

You cannot just say:

WebRTC is encrypted, therefore we're done.


Keep transfer sessions short-lived

For a real-time transfer product, there is rarely a reason for room/session state to live forever.

Short-lived sessions reduce:

  • stale data
  • attack surface
  • database clutter
  • accidental reuse

A session can expire after inactivity.

Once both peers leave, clean it up.

This keeps the coordination layer simple.


Observability becomes essential

When users tell you:

It stopped at 67%.

that is not enough information.

You need operational data that helps diagnose the failure.

Useful telemetry might include:

browser
browser version
OS
connection type
peer state transitions
ICE state
candidate type
direct vs relay
transfer size
bytes completed
transfer duration
error stage
Enter fullscreen mode Exit fullscreen mode

You do not need to inspect users' files.

You do need to understand why the transport failed.


Candidate type is useful to measure

Knowing whether the transfer used:

host
srflx
relay
Enter fullscreen mode Exit fullscreen mode

can help explain performance.

If poor-performing sessions are mostly:

relay
Enter fullscreen mode Exit fullscreen mode

that tells you something.

If failures are concentrated in one browser version, that tells you something else.

Data helps you avoid guessing.


Success rate matters more than benchmark speed

It is tempting to market:

500 Mbps transfer
Enter fullscreen mode Exit fullscreen mode

But the metric I care about more is:

What percentage of transfers actually complete?
Enter fullscreen mode Exit fullscreen mode

I would rather have:

95% completion at 200 Mbps
Enter fullscreen mode Exit fullscreen mode

than:

70% completion at 600 Mbps
Enter fullscreen mode Exit fullscreen mode

For huge files, reliability is the product.

Speed only matters after reliability.


What I learned about "no file size limit"

Technically, with a streaming architecture, you do not need a traditional server-side file-size cap because the full file is not sitting in your application memory or object storage.

But that does not mean there are no practical limits.

The real constraints become:

  • browser capabilities
  • device storage
  • device memory
  • network stability
  • transfer duration
  • filesystem support

So the phrase:

no file size limit

should mean:

the application does not impose an arbitrary product cap

not:

physics and device limitations no longer exist

That distinction is important.


Large transfer UX needs constant reassurance

A 100GB transfer can take a long time.

Users need confidence that something is happening.

Good UI should show:

  • bytes transferred
  • percentage
  • current speed
  • estimated time remaining
  • connection state
  • sender/receiver status

Something like:

47.8 GB of 100 GB
47.8%
18.4 MB/s
~47 minutes remaining
Enter fullscreen mode Exit fullscreen mode

That feels trustworthy.

A spinner does not.


ETA is harder than it looks

If you calculate ETA from the current instant speed:

eta = remainingBytes / currentSpeed
Enter fullscreen mode Exit fullscreen mode

it jumps all over the place.

Better to smooth throughput over time.

For example, using a rolling average.

Conceptually:

last 10 seconds
last 30 seconds
exponential moving average
Enter fullscreen mode Exit fullscreen mode

That produces a much calmer ETA.

Nobody likes:

12 minutes remaining
38 minutes remaining
8 minutes remaining
51 minutes remaining
Enter fullscreen mode Exit fullscreen mode

every few seconds.


Progress should be receiver-confirmed when possible

Again, this matters for trust.

If the sender has queued 80GB but the receiver has only written 75GB, showing:

80%
Enter fullscreen mode Exit fullscreen mode

may be misleading.

For long transfers, I prefer basing progress as much as possible on confirmed receiver state.

That becomes especially valuable for resumability.


Don't optimize too early

There is a temptation to start with:

  • parallel channels
  • adaptive chunking
  • compression
  • exotic scheduling
  • custom congestion tricks

Before doing that, get the fundamentals right:

  1. stable connection
  2. bounded memory
  3. backpressure
  4. progressive writing
  5. accurate state
  6. reconnect
  7. resume
  8. integrity

Only then optimize.

A boring transfer engine that finishes is better than a clever one that breaks.


When parallel data channels might help

It is tempting to open multiple data channels and send parts of the file concurrently.

That can sometimes improve throughput in specific situations.

But it also creates more complexity:

  • ordering
  • reassembly
  • congestion
  • memory
  • debugging
  • resume state

I would not start there.

One well-managed reliable channel can go a long way.

Optimize only after measurements show a real bottleneck.


WebRTC is not always the right answer

This is important.

If the sender does not need to stay online, WebRTC is probably not the right architecture.

If the recipient needs to download tomorrow, use storage.

If thousands of people need the same file, use a CDN.

If the file needs permanent access, use object storage.

WebRTC makes the most sense when:

one sender
one receiver
both online
large file
real-time transfer
Enter fullscreen mode Exit fullscreen mode

That is the sweet spot.


The architecture I prefer

At a high level:

        Signaling Server
          /        \
         /          \
        /            \
   Sender           Receiver
   Browser           Browser
      \               /
       \             /
        \           /
        WebRTC Channel
              |
              |
       Direct if possible
              |
              v
        File transfer
Enter fullscreen mode Exit fullscreen mode

With TURN when necessary:

Sender
   |
   v
TURN Relay
   |
   v
Receiver
Enter fullscreen mode Exit fullscreen mode

And the application protocol sits above the transport:

Transfer Protocol
      |
      v
WebRTC Transport
      |
      v
Network
Enter fullscreen mode Exit fullscreen mode

That separation makes everything cleaner.


What ButterShare taught me

Building ButterShare changed how I think about browser networking.

At first, I thought:

WebRTC can send binary data. Great. File transfer solved.

Now I think:

WebRTC gives me a transport primitive. Everything else is engineering.

The hardest parts were not:

new RTCPeerConnection()
Enter fullscreen mode Exit fullscreen mode

or:

createDataChannel()
Enter fullscreen mode Exit fullscreen mode

The hard parts were:

  • controlling memory
  • handling backpressure
  • surviving long transfers
  • supporting mobile browsers
  • recovering from disconnects
  • making progress trustworthy
  • keeping the protocol understandable
  • avoiding cloud storage while still handling real-world network failures

That is where the real work is.


If I were building it again

I would do these things from day one:

1. Build a transfer protocol first

Define the message types before wiring everything into the UI.

2. Treat backpressure as a first-class feature

Not an optimization.

A requirement.

3. Design resume early

Do not wait until users complain about losing 80GB of progress.

4. Write progressively on the receiver

Never assume the entire file can fit in memory.

5. Test mobile early

Desktop success will give you false confidence.

6. Measure direct vs relayed transfers

TURN usage directly affects cost and performance.

7. Build a state machine

Large-transfer state becomes too complex for a handful of booleans.

8. Separate UI from transport

Do not make networking logic depend on component lifecycle.

9. Track receiver-confirmed progress

It makes resume and UX much better.

10. Optimize only after reliability

A finished transfer beats a benchmark screenshot.


Final thoughts

WebRTC Data Channels are one of the most interesting browser technologies for direct file transfer.

They let you build something that would have required native applications not that long ago.

But the API itself is the easy part.

The moment you move beyond small demo files, you start dealing with real systems problems:

  • flow control
  • memory
  • reliability
  • networking
  • browser behavior
  • state management
  • failure recovery
  • observability

That is exactly why I enjoy working on it.

The user sees:

Send file

The engineering underneath sees:

discover peer
negotiate connection
traverse NAT
manage buffer
read chunks
send chunks
ack chunks
write chunks
track state
handle disconnect
reconnect
resume
verify
complete
Enter fullscreen mode Exit fullscreen mode

That is the gap between a demo and a product.

I am building these ideas into ButterShare, a browser-based peer-to-peer file transfer tool focused on moving large files directly between devices without relying on traditional cloud file storage.

If you have worked on WebRTC Data Channels, large-file streaming, or browser networking, I would be interested to hear which problem caused you the most pain.

ButterShare:

https://buttershare.com/


TL;DR

If you want to use WebRTC Data Channels for large file transfers:

  • Do not load the whole file into memory.
  • Chunk the file.
  • Respect bufferedAmount.
  • Use bufferedAmountLowThreshold.
  • Build backpressure into the sender.
  • Write progressively on the receiver.
  • Design a real transfer protocol.
  • Use acknowledgements.
  • Make resume possible.
  • Expect TURN.
  • Test mobile browsers early.
  • Track completion rate, not just speed.
  • Treat 100GB transfers as long-running sessions, not oversized attachments.

The biggest lesson?

WebRTC can move the bytes. Your job is making sure the transfer survives everything that happens around those bytes.

Top comments (0)