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)
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 ]
you treat it as:
[chunk 1]
[chunk 2]
[chunk 3]
[chunk 4]
...
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
}
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
If you keep doing this:
dataChannel.send(chunk)
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?
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))
}
}
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
}
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
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)
})
}
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
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"
}
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
}
]
}
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"
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
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)
}
Then:
const blob = new Blob(chunks)
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()
Then as chunks arrive:
await writable.write(chunk)
And finally:
await writable.close()
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.
Or:
Avoid locking your phone during large transfers.
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
Then:
Receiver
|
| Answer
v
Signaling server
|
| Forward
v
Sender
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
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
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
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
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
}
Now the sender knows:
5GB confirmed on receiver
That is much more meaningful than:
5GB queued locally
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
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?
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
}
Then the sender resumes from that offset.
Conceptually:
const remaining = file.slice(receivedBytes)
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
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
}
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
})
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
or:
maxPacketLifeTime
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
You want:
100%
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
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
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
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
Users do not.
They need:
Could not establish a direct connection. Retrying...
Or:
The transfer was interrupted. Reconnecting from 42.8GB...
Or:
Your device went offline. Check your connection and try again.
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
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
With side paths:
TRANSFERRING
↓
DISCONNECTED
↓
RECONNECTING
↓
RESUMING
↓
TRANSFERRING
And failure:
RECONNECTING
↓
FAILED
This structure makes your UI, analytics, and error recovery much cleaner.
It is much better than managing:
isSending
isConnected
isReconnecting
isDone
hasError
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
It deserves its own abstraction.
I prefer separating:
UI
Protocol
Transport
File IO
Session state
For example:
React UI
↓
Transfer Controller
↓
Transfer Protocol
↓
WebRTC Transport
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
For folders, you also need path information.
Example:
{
"type": "file-start",
"name": "hero.png",
"relativePath": "project/assets/images/hero.png",
"size": 834021
}
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
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
not just:
large byte count
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
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
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
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
can help explain performance.
If poor-performing sessions are mostly:
relay
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
But the metric I care about more is:
What percentage of transfers actually complete?
I would rather have:
95% completion at 200 Mbps
than:
70% completion at 600 Mbps
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
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
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
That produces a much calmer ETA.
Nobody likes:
12 minutes remaining
38 minutes remaining
8 minutes remaining
51 minutes remaining
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%
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:
- stable connection
- bounded memory
- backpressure
- progressive writing
- accurate state
- reconnect
- resume
- 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
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
With TURN when necessary:
Sender
|
v
TURN Relay
|
v
Receiver
And the application protocol sits above the transport:
Transfer Protocol
|
v
WebRTC Transport
|
v
Network
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()
or:
createDataChannel()
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
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)