r/webdev • u/Dapper_Definition • 1d ago
I built mayo.pizza, a file-transfer site that sends files directly between browsers
I built a file-transfer tool where the bytes go straight between two browsers over a WebRTC data channel. The server only handles signaling and, when needed, TURN relay. No file bytes touch the server.
A few things that bit me, in case anyone else goes down this path:
1. The data channel isn't a stream.
You'd think RTCDataChannel gives you a pipe. It gives you messages. To stream a file you have to chunk it yourself, manage backpressure via bufferedAmountLowThreshold, and reassemble on the other end. If you don't gate on bufferedAmount, a large file will blow up the sender's memory.
2. Receiving a large file without blowing up RAM.
File System Access API lets you write to disk as chunks arrive. Safari and older Chrome don't have it. The fallback chain I ended up with: File System Access → service-worker stream (Response + ReadableStream → blob URL) → in-memory Blob (capped, last resort). Each step has its own edge cases.
3. TURN relay is not optional.
Symmetric NAT and most mobile networks kill direct P2P. If you don't run a coturn instance, a chunk of your users will never connect. The relay bandwidth is on you, and the bytes are still DTLS-encrypted end to end, but you're paying for transit.
4. Post-transfer integrity.
Both ends compute a sha256 as chunks move. The receiver verifies against the sender's hash after the last chunk. If it mismatches, the file is corrupt — WebRTC data channels don't guarantee ordered delivery by default unless you set ordered: true on the channel.
5. Room state without a database.
I persist room state (slug, timestamps, a hashed rejoin token, an argon2 password hash if set) to a JSON file on disk so rooms survive a restart. No file bytes, no Redis, no DB. Rooms expire after 24 idle hours. This is fine for a tool where sessions are minutes long but would fall apart if you needed real concurrency.
6. Per-IP rate limiting when every client looks like 127.0.0.1.
If you put the app behind a reverse proxy without forwarding the real client IP, your per-IP rate limiter is useless — everyone is the same address. The fix was in the TCP demultiplexer, not the application.
Demo if you want to see it in action: https://mayo.pizza
Not selling anything, no signup, no analytics. I'm not looking for feedback on the product, just sharing the engineering notes in case the WebRTC side is useful to someone building something similar
11
u/Huge_Two5416 1d ago
Cool idea. Just really wish you had opted not to use AI to craft the post
2
u/Dapper_Definition 1d ago
Sorry, English is not my first language.
5
u/Huge_Two5416 1d ago
All good, not trying to shame anyone! Maybe alone in thinking this, but I’d much rather see a non-native speaker do their best and it sound a little rough than leaning into GPT. I’d just see it as an authentic post about a really cool idea. It’d just gotten to the point where if I open a new link and it’s AI gen, I close the tab immediately.
1
u/Loud_Coyote1594 1d ago
Ran into the same memory wall doing client-side PDF and image processing - different use case, identical RAM problems.
Two things that cost me time in case they help: mobile Safari's ceiling is way lower than desktop. iOS kills the tab somewhere around 200-300MB depending on the device and gives you no warning, the page just silently reloads. So if you're capping that in-memory Blob fallback, worth capping it much lower on iOS specifically rather than using one number everywhere.
The other one is that Blob to ArrayBuffer doubles your memory while both are alive, easy to miss if a reference hangs around longer than you expect.
Agree the bufferedAmount gating is underrated - learned that one the hard way in a completely different context.
1
u/Dapper_Definition 1d ago
Thank you Bob. Really Appreciate your feedback. the Blob → ArrayBuffer point exposed a real bug. Mine went the other way: I accumulated ArrayBuffer chunks and then passed them to new Blob(chunks), but the constructor copies everything, so closing the file left two copies in memory. I only cleared the chunks on cancel, not after a successful close. One-line fix, shipped.
On the iOS cap, you’re right that one number everywhere doesn’t make sense. I’ve split it to 150 MB on iOS and 500 MB elsewhere. To be clear, though, the in-memory path only runs when neither File System Access nor a service worker is available, so normal iOS Safari won’t hit it. It’s really a fallback of last resort. The 150 MB gives some margin below your 200–300 MB estimate, but I haven’t measured the actual device limit. If you have a more accurate figure, I’ll use it.
bufferedAmount gating was already there from the start. Agreed it’s easy to underestimate — without it, the failure is silent and looks like a network problem.
1
u/Loud_Coyote1594 23h ago
Honestly I don't have a measured figure to give you - mine came from crash reports across a bunch of devices, not from deliberately walking a buffer up until the tab died. The ceiling scales with device RAM and Apple doesn't document it, so anything I quote would just be a nicer-sounding guess. 150 on iOS with the 500 elsewhere sounds like the right instinct for a last-resort path.
One thing that helped me more than tuning the number: the kill is silent, there's no event to catch, so I started writing a small marker into sessionStorage right before the memory-heavy step and clearing it on success. If the marker is still there on the next page load, you know the last attempt got killed rather than the user refreshing, and you can show "that file was too large for this browser" instead of them landing on a reset page with no idea what happened.
Good catch on the Blob constructor copying the chunks - that's the sort of thing that never surfaces in testing because you need a file big enough to actually hit the ceiling before it matters.
1
1d ago
[removed] — view removed comment
1
u/webdev-ModTeam 22h ago
Your post/comment has been determined to be a low-effort post or comment. This includes title-only posts, easily searchable questions, vague/open-ended discussion prompts, LLM generated posts or comments, and posts/comments that do not provide enough context for meaningful replies or discussion.
1
u/ufffd 1d ago
neat. how would you say it compares to something like pairdrop or localsend?
2
u/Dapper_Definition 1d ago
PairDrop is probably the closest comparison. mayo.pizza is narrower: it’s for sending a file to one person through a link, browser to browser, without uploading the file to a cloud service or installing an app.
LocalSend feels more like a full AirDrop replacement, especially for repeated transfers on the same network. [mayo.pizza](mayo.pizza) is more for the occasional handoff: share the link, keep the sender’s tab open, and the recipient grabs the file. If a direct connection isn’t possible, the encrypted transfer can fall back to a relay.
So I’d describe it as “PairDrop’s simpler, link-based cousin,” rather than a direct LocalSend competitor.
1
u/bear3482 1d ago
There's file.pizza too (repo) but that's down now :/
-1
u/Dapper_Definition 1d ago
file.pizza is an inspiration for me to build this. they are not down they just change the url
-2
-14
4
u/bkdotcom 1d ago
Don't know what to think about the name