Four Evolutions of a WebRTC Chat Room: From Anonymous Voice to Human-Agent Collaboration
- Two minutes of WebRTC background
- Version 1: Go + Pion, self-hosted SFU
- Version 2: Elixir + Membrane
- Version 3: Cloudflare RealtimeKit, outsourcing the media
- Version 4: Cloudflare Realtime SFU, taking the application semantics back
- Comparing the four versions
- The Room stayed the same; the participants changed
- What real use has already proven
- What free4chat deliberately does not want to become
- Conclusion
- Further reading
In early 2022 I posted free4chat on V2EX under a title that roughly translated to “I built a WebRTC voice chat room, and the result surprised me.” A single 1-core, 1 GB AWS instance handled close to a thousand concurrent visitors while barely moving the load average.
I have rewritten it three times since then. The first version was not broken—it ran fine. Each rewrite happened because the project hit a new boundary: I wanted to understand WebRTC, then to stop maintaining servers, then to push the whole system serverless. Along the way I learned that managed infrastructure does not remove complexity; it hands complexity and control to the vendor together.
The original motivation was simple: a room I could open whenever I needed to talk—no account, no app install, just a link. Zoom, Google Meet, and Tencent Meeting are far more capable, but they all ask you to organize people first. That part of free4.chat has never changed: create a temporary room when you need to collaborate, let it disappear afterward, and never build a durable organization just to talk. At first only people joined, so the product looked like an anonymous voice chat room; the participants now include Agents, but “temporary, use it and leave” still holds.
I first wrote this story in May 2026, right after the third version moved to Cloudflare RealtimeKit (RTK), and I was quite positive about the all-Cloudflare setup. In August, a production session-lifecycle and billing anomaly changed that judgment. free4chat moved to the lower-level Cloudflare Realtime SFU, and the article went from “three evolutions” to four. I kept the RealtimeKit chapter because it was a real decision made with what I knew at the time—but the original recommendation no longer stands, and the incident section explains why.
Then something happened after the fourth migration that I did not expect: the Room stayed the same, and the participants changed. free4chat originally connected people; the same temporary Room can now host Humans, independently running Agents, or several Agents working together. There was no fifth infrastructure rewrite—the “temporary room” abstraction simply grew a new use in the Agent era.
Two minutes of WebRTC background
WebRTC does not define a signaling protocol: it moves media, and applications decide how peers exchange connection information (SDP). Signaling and media are separate, and every rewrite below is, at heart, a new answer to who owns which part.
Most users sit behind NAT. ICE finds a path: try the local address first, then a STUN-discovered public mapping, and fall back to TURN relay if nothing works. STUN carries almost no media; TURN is a real relay server, and once traffic lands on it, the bandwidth and reliability burden is yours.
Media must be encrypted: peers negotiate keys with DTLS and send audio/video over SRTP. So if a server wants to record, transcode, or let an Agent actually speak, it has to enter the WebRTC media plane as a peer—handling HTTP requests is not enough.
For multiparty media there are three common architectures. P2P meshes explode in connection count and upstream bandwidth as rooms grow. An MCU decodes, mixes, and re-encodes on the server: easy clients, expensive server, extra latency. An SFU forwards tracks selectively without decoding, which is why it dominates modern multiparty WebRTC. free4chat is audio-heavy with low bitrates, which keeps SFU costs low—but “an SFU doesn’t use CPU” is not a law; video, Simulcast, and large-room fan-out change the picture quickly.
Version 1: Go + Pion, self-hosted SFU
The first version used kraken from the Pion ecosystem. Pion is a full WebRTC stack in Go: ICE, DTLS, SRTP, SDP, and DataChannels are all directly controllable.
graph LR
A[Browser A] -->|WebSocket signaling| S[Go Server\nSFU + STUN/TURN]
B[Browser B] -->|WebSocket signaling| S
A <-->|DTLS/SRTP media| S
B <-->|DTLS/SRTP media| S
The whole backend ran on one 1-core, 1 GB AWS instance. The product was mostly low-bitrate audio, the SFU never had to decode and re-encode streams, and the V2EX launch spike—close to a thousand concurrent visitors—barely registered on the server.
The best part was transparency: when something broke, I could follow it down from ICE and SDP through DTLS to RTP. The costs were equally clear: a single machine was a single point of failure, STUN/TURN needed operating, security groups and UDP port ranges needed configuring, kraken leaked some connection resources, and horizontal scaling would have meant building a coordination layer myself.
Version 1 taught me how WebRTC actually fits together. It also taught me that for a side project, the media server itself consumes maintenance time forever.
Version 2: Elixir + Membrane
The second version moved to Elixir and Membrane. Go was fine; I wanted to see whether Erlang/OTP’s process model and clustering made realtime systems more natural to express. It did: rooms, connections, and media pipelines became independently supervised processes, failures stayed isolated, and nodes found each other over Erlang Distribution without an extra Redis Pub/Sub layer. This version also added text chat over Phoenix Channels, and application state and clustering became noticeably more elegant.
But the media plane was still mine. coturn, EC2, security patches, node deployment, monitoring, port mapping—nothing went away. In a company that is standard infrastructure work; in a side project it keeps interrupting the product itself. That is what triggered the third rewrite.
Version 3: Cloudflare RealtimeKit, outsourcing the media
The question behind version 3 was direct: what if I stop operating the SFU entirely?
At the time the answer was Cloudflare RealtimeKit, originally from Dyte. The frontend and API ran on Cloudflare Workers, KV handled rate limiting, Durable Objects held room and AI session state, and RealtimeKit took media, STUN/TURN, and WebRTC sessions. I built this version with OpenCode and Claude Sonnet 4.6 over a weekend—the first two versions were written by hand, and by this point coding Agents had started to materially change how fast one person could ship.
The reasons for choosing RealtimeKit were all valid then: no SFU servers or coturn to maintain, no public UDP ranges or security groups, an SDK that already modeled participants, tracks, and meeting lifecycles, and everything else on the same platform. For an individual developer, every server, deployment path, and monitoring dashboard that disappears is cognitive overhead that disappears with it. The original article recommended it enthusiastically for exactly that profile.
I withdraw that recommendation now.
Even without the incident, RealtimeKit had an inherent trade: it is a high-level meeting platform, not a low-level SFU API. The application gains participant, preset, and meeting abstractions and gives up media-plane control—for example, having a server-side AI join as a real WebRTC peer and drive tracks directly is limited by the high-level API. That alone was a normal trade of convenience for control; what changed my judgment was a production incident in August 2026.
August 2026: the production incident that made me leave RealtimeKit
A scope note first: Cloudflare Engineering is still investigating, and no full engineering root cause has been published yet. What follows is limited to production state I could reproduce and preserve myself.
At the time of the incident, RealtimeKit was still in Beta. Its pricing documentation said Beta usage was free and listed the planned participant-minute rates: $0.002/min for Audio/Video Participants and $0.0005/min for Audio-Only Participants. My bill nevertheless showed RealtimeKit Participant charges: $147.62 for the first billing period, still growing after that. Because the charge kept increasing, on August 21 I urgently moved production media traffic off RealtimeKit and onto Cloudflare Realtime SFU. By late August 2026 RealtimeKit had left Beta; the current pricing page lists both rates directly.
The application had stopped using RealtimeKit, yet the bill kept growing: August 22 and 23 still showed roughly $29/day of Audio/Video Participant usage. I first assumed billing lag. A full API audit of the app found something worse: the app held 2,440 Meetings and 2,609 Sessions, of which 12 Sessions were still marked LIVE, containing exactly 10 live participants, while every corresponding Meeting was already INACTIVE. The oldest stale session had been live since August 1 and was still LIVE on August 25—long after production clients had left and stopped generating RealtimeKit traffic.
The numbers matched the bill almost exactly:
10 participants × 1,440 minutes/day × $0.002/min
= $28.80/day
The actual charge shown for August 23 was:
$28.76/day
A difference of $0.04.
To stop the bleeding while preserving evidence, I saved the full Apps, Meetings, Sessions, and active-session API responses and then used the official kick-all API to remove every remaining participant. After cleanup, live participants dropped to zero and all 2,440 Meetings were INACTIVE; the historical data stayed available for Cloudflare Engineering. There was also a classification issue: I used the Voice preset, which the documentation says should be billed as Audio-Only, but the bill counted those minutes as Audio/Video. Both issues were escalated to Realtime Engineering in the same ticket.
Ten stale LIVE participants matching roughly $28.8/day of participant-minute charges does not prove the internal root cause, but the fit is hard to ignore.
The incident changed how I think about per-minute billing. Charging by time is fine; the problem is that when “is this participant online” is decided by server-side session state, the lifecycle needs forced timeouts, reconciliation, and billing safeguards—otherwise the client leaves and the billing clock keeps running. And a managed-service customer can hardly see that drift from their own telemetry: my business traffic had stopped and the Meetings were INACTIVE, yet the server kept LIVE participants for days or even weeks.
Until Cloudflare publishes a clear root cause and mitigation, I do not recommend RealtimeKit for production workloads that need predictable participant-minute billing.
Version 4: Cloudflare Realtime SFU, taking the application semantics back
This time I did not go back to Go/Pion or rebuild the Elixir cluster. I stayed on Cloudflare and swapped the media layer from the high-level RealtimeKit to the lower-level Cloudflare Realtime SFU. The media infrastructure stays serverless, but room, participant, session, track, and application lifecycle semantics are back under free4chat’s control.
graph TB
UA[Human Browser A] <-->|WebRTC audio / screen| SFU[Cloudflare Realtime SFU]
UB[Human Browser B] <-->|WebRTC audio / screen| SFU
UA <-->|DataChannel files / images| UB
UA --> W[Cloudflare Worker]
UB --> W
W --> TS[Turnstile\njust-in-time]
W --> RS[RoomSession\nDurable Object]
W --> SFU
AR[Local Agent Runtime\nGo + in-process Pion] <-->|MCP room protocol| W
AR <-->|optional granted media| SFU
AR <-->|ACP| H[Codex / Claude / Pi / Hermes / ...]
RS -->|presence / text / collab / expiry| UA
RS -->|presence / text / collab / expiry| UB
RS -->|bounded room context| AR
Browsers connect directly to Realtime SFU for voice and screen sharing. A Cloudflare Worker authenticates and creates SFU sessions, and Turnstile no longer blocks the page—it runs just-in-time, only when a new session is actually created. Room state lives in a hibernating RoomSession Durable Object: presence, mute state, text messages and reactions, reconnect/resync, collaboration events, ephemeral attachments that die with the room, and room expiry. Large Human-to-Human files still travel over DataChannels and never enter a permanent database; small files Agents need can sit as temporary attachments within the room’s lifetime and disappear with it.
The Agent side is no longer the early hosted bot. free4chat ships a self-contained local Go Runtime with in-process Pion for optional realtime media; it joins the Room over MCP and connects over ACP to existing Agent runtimes (Harnesses) such as Codex, Claude, Pi, Hermes, and OpenCode. The Runtime handles participant leases, event waits, and reconnect/rejoin; the model, tools, credentials, permissions, and private memory stay with the user’s own Harness.
Why not back to a self-hosted SFU? Because the problems from versions 1 and 2 never went away: I don’t want to operate media servers, TURN, and security patching for this project again. The difference between RealtimeKit and Realtime SFU is where the responsibility sits: the former hosts meeting, participant, and session lifecycle as part of its product; the latter is closer to a programmable media switchboard—Cloudflare runs the global media infrastructure, and the Room and business semantics are mine. Version 4 has more application code, but when something goes wrong I can see who my application thinks is online, which SFU session exists, how tracks are published and subscribed, and decide myself when to clean up.
Billing is also easier to reason about. Realtime SFU charges for data actually sent from Cloudflare’s edge to clients: $0.05/GB, the first 1,000 GB per month free, and no charge for client-to-Cloudflare ingress. Usage-based pricing is not automatically cheaper—video and high bitrates still generate real egress—but I prefer the risk model: when no media is being sent, no per-minute clock is ticking, and the billing unit is close to an observable network resource. At free4chat’s current audio-heavy scale, the free tier is far larger than actual usage.
Comparing the four versions
| Dimension | Go + Pion (v1) | Elixir + Membrane (v2) | Cloudflare + RealtimeKit (v3) | Cloudflare Realtime SFU (v4) |
|---|---|---|---|---|
| Media infrastructure | Self-hosted | Self-hosted | Fully managed meeting platform | Managed low-level SFU |
| SFU control | Full | Full | Low | Higher; app owns session/track semantics |
| Application state | Application-owned | OTP / GenServer | RTK + Cloudflare app layer | Durable Objects |
| Scaling | Build it yourself | OTP-native clustering | Platform-managed | Platform media + DO room state |
| Operational cost | Medium | Medium-high | Lowest | Low |
| Debug transparency | Highest | High | Lower | Higher than RTK |
| Billing model | VPS + bandwidth | VPS + bandwidth | Participant-minute (GA) | Egress GB |
| Custom media control | Highest | Highest | Constrained by high-level API | Good fit for custom routing/tracks |
| Used by free4chat today | No | No | No | Yes |
| My current recommendation | Learning / deep customization | OTP-heavy systems you can operate | Wait for lifecycle/billing clarity | Serverless media with app-level control |
If only development speed matters, RealtimeKit is still the fastest. If only control matters, self-hosted Pion still wins. For my current needs, Realtime SFU is the better balance: no media server to operate, without handing the application’s session state to a higher-level hosted product.
The Room stayed the same; the participants changed
After version 4 shipped, I planned to keep experimenting with “putting AI into the realtime room.” The interesting part turned out to be the participant abstraction: extending it from Humans to independently running Agents, not bolting another AI feature onto a chat room.
My earlier bot followed the classic model + prompt + tool calling pattern: run a model on the server, listen to room text, reply. That works as a demo, but useful Agents stopped being just models a while ago. Codex, Claude Code, Pi, and Hermes carry their own model, context and memory, tools, local files and runtime, logged-in credentials, security policies, and human-approval boundaries. Re-hosting all of that inside free4chat would duplicate it and break the product’s lightweight premise.
So the direction inverted: don’t move the Agents—let them connect temporarily. The Room now supports three kinds of collaboration—Human to Human, Human to Agent, Agent to Agent. An Agent can create a room, advertise a small capability list, discover other participants, and send a collaboration request; the target decides whether to accept, does the work itself with its own runtime, tools, and permissions, and returns results and files. Advertising a capability is not authorization, and a collaboration request is not a remote function call.
Human-to-human chat never went away; it became one case of a more general participant model. free4chat now looks like a very thin temporary collaboration space: the Room lets participants discover each other and exchange explicitly shared context, requests and results, files, and media, while intelligence, tools, credentials, permissions, and durable memory stay with the participants. One sentence: the Room provides the temporary collaboration space; participants bring the capabilities.
What real use has already proven
Shared live transcript
One flow, explicitly started by a Human: an authorized STT-capable local Runtime Host subscribes to the room’s audio, runs streaming STT with the user’s locally configured speech credential, and commits attributed text as a room-wide live transcript. That transcript becomes temporary, room-scoped shared context and disappears with the room.
Committing a transcript does not automatically wake resident Agents. Only when an Agent is explicitly addressed does its runtime include the transcript in its next turn. And a direct MCP client can read the room transcript through the Room API anyway, so addressing is not an access-control boundary—it is the switch that decides whether a resident runtime gets activated.
Cross-machine Agent collaboration
Real production use has exercised this chain: Agent A on a Mac mini sends a structured collaboration request and attaches a file to the room; Agent B, running independently on a MacBook, reads the actual attachment, does the local work under its own runtime, tools, and permissions, produces a real result file, and returns a correlated result plus the file to A through the room. No shared filesystem, no clipboard relay, no S3/scp/Git as artifact transport, no central planner, and no Free4Chat-owned Agent brain.
What it validated is the Room itself: requests and files flow through the room, while computation and decisions happen on two independent machines. The next product question is concrete: how to make this proven cross-machine collaboration easier to enter from a terminal, instead of opening a browser every time.
The voice path
Two voice capabilities are complete and have been used in real life. One is the room-wide live transcript above. The other is Agent Voice Reply: after explicit Human authorization, the Agent runtime’s text reply is synthesized with TTS and published back into Cloudflare SFU through a Pion track, so people in the room hear the Agent directly. The baseline path speech → STT → Agent runtime → text → TTS → speech exists; the transcript is collaboration infrastructure, not an automatically speaking bot, and not a permanent archive.
Realtime speech-to-speech models (newer OpenAI or Doubao voice APIs, for example) remain a possible separate experiment for lower latency, natural interruption, and continuous spoken interaction—not a promised roadmap item. And even if that works, it should not replace the Agent runtime: a more interesting shape is a realtime voice Agent that converses naturally with Humans and, when real work shows up, finds a tool-carrying Agent through the Room, sends a collaboration request, and speaks the result back.
What free4chat deliberately does not want to become
Enterprise collaboration products grow out of a permanent workspace: organization, accounts, permissions, channels, history, and search all grow around it. They are far more capable—but free4chat is answering a thinner question: I need these people and these Agents to work together right now; can we skip moving everyone into the same organization first?
So free4chat deliberately does not become a central Agent orchestrator, a planner or scheduler, a permanent project workspace, a central memory or knowledge base, a credential vault, or an Agent marketplace, and it is not trying to replace Slack. Intelligence, credentials, private memory, project history, and orchestration stay with the participants; free4chat provides only the temporary room. Agents keep running on laptops, phone sandboxes, Mac minis, VPSes, or corporate machines, entering the same room only for the duration of a collaboration. If the model holds, free4chat looks like a temporary LAN for the Agent era: no centralization of compute, just a cheap, short-lived collaboration network for participants who remain independent.
Conclusion
Four versions in, I have become more conservative, not less: there is no best stack, only a stack whose boundaries match the problem. Version 1 taught me the WebRTC layers; version 2 showed me how expressive OTP can be for realtime systems; version 3 demonstrated the pull of zero-operations; version 4 added the most important lesson: managed services do not eliminate operational risk; they redistribute it. The Human + Agent work after version 4 taught something else: a simple abstraction can gain a new use when the environment around it changes—this room existed to connect people temporarily, and with Agents it now temporarily connects people and systems with different models, tools, and context.
That is not an argument for the product to grow. The boundary worth protecting is still the original one: temporary, no shared account, no central intelligence, no permanent memory. I still like Cloudflare for independent projects—Workers, Durable Objects, Turnstile, KV, and Realtime SFU compress a lot of server and DevOps work into a little code and usage-based infrastructure, which genuinely expands what one person can maintain. But platform uniformity is not a migration reason by itself. The closer a service sits to core data, critical messaging, or realtime media, the more I ask: when it breaks, can I see the real state? Can I stop the damage myself? Does billing correspond to consumption I can observe? If the vendor’s state and my application’s state disagree, who carries the risk?
Moving from RealtimeKit to Realtime SFU meant moving one abstraction layer downward inside Cloudflare, not leaving it. The product direction is the same choice: rather than a heavier Agent platform, free4chat stays a thin collaboration layer that leaves capabilities with the participants.
Try the current version: https://free4.chat
The source code is still in the same repository: free4chat
golang: Go + Pion version;elixir: Elixir + Membrane version;cloudflare: historical Cloudflare + RealtimeKit version;cf-sfu: current Cloudflare Realtime SFU + Human/Agent collaboration version.
Cloudflare has since refunded the disputed charge. The full engineering root cause and mitigation for the session-lifecycle/metering discrepancy have not been publicly resolved yet; I will update this section if a final engineering explanation becomes available.
Further reading
- free4chat on GitHub — current source, local Agent Runtime, and experiment roadmap
- Cloudflare RealtimeKit Pricing — Beta/GA participant pricing
- Cloudflare RealtimeKit Audio Only Calls — Voice preset and Audio-Only Participant classification
- Cloudflare Realtime SFU Pricing — SFU egress pricing and free allowance
- WebRTC for the Curious — an excellent open book on the WebRTC protocol stack
- Pion WebRTC Examples — one of the best ways to learn WebRTC through Go
- Cloudflare Durable Objects — Cloudflare’s stateful compute model
- A New Experience in Real-time Web Application Development — my earlier article on realtime systems with Elixir