Building Live: How I Added Real-Time Streaming to Circlenet Solo
A few weeks ago, Circlenet didn't have a Live button. Now it does, and I want to walk through what it took to get there: the architecture decisions, the bugs that ate my evenings, and what it actually feels like to build a real-time video feature by yourself.
Why Live
Circlenet already had a lot of the ingredients for a social platform: posts, Groups, Articles, anonymous Whisper messages, encrypted DMs. But none of it was live. Everything was asynchronous. You post, someone reacts later, you check back tomorrow.
I wanted a feature that made the app feel alive in the moment, something that rewards being online right now. Live streaming was the obvious answer, but it's also one of the harder things you can build as a solo developer, because it touches almost every layer of the stack at once: media capture, peer-to-peer networking, signaling, UI state, and the existing social graph.
The Shape of the Feature
Before writing code, I sketched what "done" looked like:
- A host can go live from a modal, preview their camera, and start broadcasting.
- Viewers see a horizontally scrolling strip of who's currently live, and get a toast notification when someone new goes live.
- Inside the live view, viewers can chat, send emoji reactions that float up the screen, and tap the video to send hearts, similar to the double-tap-to-like pattern people already know.
- The host sees a live viewer count and can toggle mic and camera without ending the stream.
None of this needed to be groundbreaking. It needed to feel familiar, low-friction, and fast.
Architecture: WebRTC Over an Existing WebSocket
The core decision was how video actually gets from the host's camera to a viewer's screen. I went with WebRTC for the media itself, using the browser's RTCPeerConnection, and reused Circlenet's existing WebSocket context as the signaling channel instead of standing up a separate signaling server.
That reuse mattered a lot. Circlenet already had a WsContext handling real-time notifications elsewhere in the app, so Live's signaling messages (live:offer, live:answer, live:ice_candidate, live:viewer_joined, and so on) just ride the same socket as everything else. One connection, one reconnect strategy, one less thing to maintain.
The peer topology is a simple mesh: the host opens one RTCPeerConnection per viewer. When a viewer joins, the host creates an offer, sends it down the WebSocket, and the viewer answers back. ICE candidates trickle both ways over the same channel. It's not the most scalable approach (a mesh doesn't hold up if you get hundreds of simultaneous viewers on one stream), but for where Circlenet is right now, it's the right tradeoff. An SFU (selective forwarding unit) is the "correct" answer at scale, but running one is infrastructure I don't need yet, and building one solo would have delayed shipping by months for a problem I don't have.
I keep a small state machine in a LiveContext provider: role (host or viewer), session id, the local and remote MediaStream objects, chat messages, viewer count, and like count. Everything else, the setup modal, the overlay, the feed strip, the toast, just reads from that context. That separation made the UI components almost boring to write once the context worked, which is exactly what you want.
The Bugs That Actually Hurt
A few problems took real time to track down.
The offer race. When a viewer joins, they create their RTCPeerConnection and start listening before the host necessarily knows they've joined. If the host's offer gets sent before the viewer's connection is ready, or if the WebSocket message arrives out of order, the viewer just sits there with no stream. I added a retry timer on the viewer side: if no offer arrives within a few seconds of joining, the viewer resends a join request. It's not elegant, but distributed systems rarely reward elegance over correctness.
Stale peer connections. Early on, if a viewer refreshed the page or lost connection mid-stream, the host kept a dead RTCPeerConnection around, silently doing nothing. I now clean up peer connections on live:viewer_left and close them explicitly, which also stops leaking memory during longer streams.
The "is this actually connected" problem. WebRTC gives you connection state events, but they don't map cleanly to "the viewer sees video now." I ended up watching for the ontrack event as the real signal that a stream is flowing, and I show a loading state with a manual retry button if nothing arrives within five seconds. Users don't wait around for spinners that don't explain themselves, so the retry button mattered more than I expected.
Mic and camera toggles without renegotiation. Muting audio or disabling video shouldn't tear down the whole peer connection. I handle this by disabling tracks (track.enabled = false) rather than removing them, so the connection state stays untouched and there's no renegotiation flicker on the viewer's end.
Small UX Details That Took Disproportionate Effort
The tap-to-heart interaction, where tapping anywhere on the video sends a floating heart from that exact point, took longer to get right than the WebRTC signaling did. Getting the animation to feel snappy meant hand-rolling it with requestAnimationFrame instead of relying on CSS transitions, because I needed to clean up each heart element the moment its animation finished rather than trusting a fixed CSS duration.
The floating emoji reactions have a similar shape but broadcast to everyone in the session over the WebSocket, so what one person sends, everyone sees drift up their screen a moment later. Getting the horizontal spread randomized just enough that reactions don't stack in a single column took some fiddling with the numbers.
The live feed strip and toast notification are the quietest parts of the feature, but they do a lot of work for discovery. Without them, Live is invisible unless you already know someone is streaming. The strip polls active sessions every 30 seconds, and the toast fires once per new session so people don't get spammed if three friends go live around the same time.
What I'd Do Differently
If Circlenet's live viewership grows past a handful of concurrent viewers per stream, the mesh topology needs to go. I'd want to move to an SFU, something like a self-hosted mediasoup instance, so the host uploads one stream and the server fans it out, rather than the host's connection scaling linearly with viewers. That's a real infrastructure project, not a weekend one, and I'm deliberately not building it before I need it.
I'd also want persistent chat history for streams, right now chat only exists for the duration of the session and disappears when it ends, which is fine for now but limits any kind of highlights or replay feature later.
The Solo Part
Building this alone meant there was no one to hand off the WebRTC debugging to when it got frustrating, and no one else reviewing whether the mesh approach was the right call. But it also meant I could move through the whole feature end to end, media capture, signaling, UI, discovery, without waiting on anyone else's timeline. That tradeoff is basically the whole story of building Circlenet so far: slower in isolation, but every decision is one I actually understand top to bottom.
Live is up on Circlenet now. If you want to see it in action, go live yourself and see who shows up.


