Zero Egress Costs: How I Built P2P File Sharing with Cloudflare

Published: (January 9, 2026 at 12:05 AM EST)
5 min read
Source: Dev.to

Source: Dev.to

I built a P2P file‑sharing tool where files transfer directly between browsers. The server only handles WebRTC signaling — actual files never touch it. Transfer a 10 GB file? Still zero egress costs.
Stack: Hono + Cloudflare Workers + Durable Objects + STUN.

The Problem: Egress Costs Add Up Fast

Every file‑sharing service charges you for bandwidth. S3, R2, whatever — you pay for every byte that leaves the server.

I ran the numbers for a simple use case: sharing large video files with a few friends. Even with Cloudflare R2’s “generous” free tier, a few 4 GB files per month already cost money. Scale that to real users and the bill gets ugly.

I wanted something different: zero transfer costs, regardless of file size.

The answer was obvious in hindsight — don’t let files touch the server at all.

The Solution: WebRTC + Cloudflare

WebRTC lets browsers talk directly to each other. No server in the middle. The catch? You still need a server for signaling — exchanging connection info so browsers can find each other.

Architecture

┌─────────────┐         ┌─────────────────────┐         ┌─────────────┐
│   Sender    │◄───────►│   Durable Object    │◄───────►│  Receiver   │
│             │   WS   │   (signaling only) │   WS   │             │
└─────────────┘         └─────────────────────┘         └─────────────┘
       │                                                       │
       │                                                       │
       └──────────────────── WebRTC P2P ───────────────────────┘
                         (files go here)

Signaling messages are tiny — a few KB. Files flow directly between browsers. The server never sees them.

The Stack

LayerTechWhy
FrameworkHonoTypeScript‑first, perfect Cloudflare integration
HostingCloudflare WorkersEdge deployment, cheap
StateDurable ObjectsWebSocket connections + room state
NAT traversalCloudflare STUNFree, same vendor

Everything stays within Cloudflare. One wrangler deploy and it’s live.

Why Durable Objects?

Workers are stateless. That’s fine for most things, but signaling needs state — you have to track who’s in which room and relay messages between them.

Durable Objects solve this perfectly. Each room gets its own instance:

app.get('/ws/:roomId', (c) => {
  const roomId = c.req.param('roomId')
  const id = c.env.ROOM.idFromName(roomId)
  const stub = c.env.ROOM.get(id)
  return stub.fetch(c.req.raw)
})

The Durable Object handles all WebSocket connections for that room. When someone sends an offer, it relays it to the right peer. Simple.

export class Room extends DurableObject {
  async fetch(request: Request): Promise {
    const clientId = new URL(request.url).searchParams.get('cid')
      ?? crypto.randomUUID()

    this.closeDuplicateClient(clientId) // Handle reconnects

    const pair = new WebSocketPair()
    this.ctx.acceptWebSocket(pair[1])

    return new Response(null, { status: 101, webSocket: pair[0] })
  }

  webSocketMessage(ws: WebSocket, message: string) {
    // Relay signaling messages to the right peer
  }

  private closeDuplicateClient(clientId: string) {
    for (const socket of this.ctx.getWebSockets()) {
      const attachment = socket.deserializeAttachment()
      if (attachment?.cid === clientId) {
        socket.close(1000, 'replaced')
      }
    }
  }
}

The Hard Part: Reconnection

Getting the initial connection working took a day. Making reconnection reliable took a week.

Problem 1 – Ghost Connections

When the user reloads the page, the browser closes the WebSocket, but the Durable Object doesn’t know immediately — there’s a delay before webSocketClose fires. A new connection arrives, resulting in duplicate sockets.

Fix: Store a persistent client ID in localStorage.

function getClientId() {
  const stored = localStorage.getItem('client-id')
  if (stored) return stored
  const id = crypto.randomUUID()
  localStorage.setItem('client-id', id)
  return id
}

When a new connection arrives with the same client ID, force‑close the old one (see closeDuplicateClient above).

Problem 2 – Stale Signaling Messages

Old offer/answer messages from the previous session can arrive after reconnection, mixing with the new session and breaking everything.

Fix: Attach a session ID to every signaling message.

// Sender
const sendOffer = async (peer: OffererPeer) => {
  const sid = ++peer.signalSid          // Increment on every new offer
  peer.activeSid = sid

  const offer = await peer.pc.createOffer({ iceRestart: true })
  await peer.pc.setLocalDescription(offer)

  send({ type: 'offer', to: peer.peerId, sid, sdp: offer })
}

// Receiver
if (msg.sid !== peer.activeSid) return // Ignore stale messages

Client IDs handle duplicate connections; session IDs handle stale messages. Together they make reconnection stable.

The No‑TURN Trade‑off

I deliberately skipped TURN servers. TURN relays traffic through a server when P2P fails (e.g., strict corporate firewalls, symmetric NAT). Using TURN would defeat the whole point — files would go through my server, incurring egress costs.

Without TURN, some corporate networks won’t work. That’s the trade‑off. For my use case — sharing files with friends and colleagues on typical home/office networks — STUN alone works fine.

If I needed to support stricter environments, I’d add TURN as an option and charge for it. But the free tier stays P2P‑only.

Bonus: E2E Encryption

Optional end‑to‑end encryption using URL fragments:

https://example.com/room/ABC123#k=Base64EncodedKey
  • Enter fullscreen mode
  • Exit fullscreen mode

The # fragment never reaches the server. Cloudflare Workers never see the key; only browsers sharing the link can decrypt.

What I Learned

  • Durable Objects are underrated. Everyone talks about Workers, but Durable Objects are what make stateful edge applications possible—WebSocket management, room state, connection queueing—all in one primitive.
  • WebRTC reconnection is painful. The happy path works quickly. The reconnection edge cases take 10× longer. Budget for it.
  • TURN is a business decision, not a technical one. You can always add it later. Starting without it keeps costs at zero and forces you to validate whether P2P alone is good enough.
  • The Cloudflare stack is underrated for real‑time apps. Workers + Durable Objects + STUN = no external dependencies, one deploy command, and it just works.

The best file transfer is the one that never touches your server.

Back to Blog

Related posts

Read more »

Hello, Newbie Here.

Hi! I'm falling back into the realm of S.T.E.M. I enjoy learning about energy systems, science, technology, engineering, and math as well. One of the projects I...