SAMPARK — What Building a WebRTC Meeting Platform Teaches You About State
SAMPARK — What Building a WebRTC Meeting Platform Teaches You About State
Peer-to-peer media is the easy part. Knowing who is in the room is the hard part.
SAMPARK (Synchronous Audio-Meeting & Peer-to-peer Relay Kit) is a real-time video conferencing platform: WebRTC for media, a Socket.IO signalling server for coordination, Express for the room and session APIs, and MongoDB for persistence.
The naive mental model of a video call app is "stream video between browsers." That part WebRTC hands you almost for free. Everything difficult in SAMPARK lives in the layer nobody demos: room membership, reconnection, and the fact that a peer connection can die without telling anyone.
Media Goes Peer-to-Peer, Coordination Goes Through the Server
The single most consequential architectural decision is that the server never touches media.
Two participants negotiate a direct RTCPeerConnection; audio and video flow browser to browser. The server's job is to pass the negotiation messages — SDP offers, SDP answers, and ICE candidates — between peers who cannot yet talk to each other directly.
Why this matters concretely:
- Bandwidth cost stays flat. A media-relaying server (an SFU) pays for every stream of every participant in every call. A signalling server pays for a handful of JSON messages per join. For a student project that's the difference between free and impossible.
- Latency is lower. The media path is one hop instead of two.
- The server can restart without killing calls. Existing peer connections survive a signalling server bounce, because they aren't routed through it. New joins fail until it's back; established calls keep running.
The cost is equally honest: mesh peer-to-peer scales as O(n²) connections. Four participants is twelve peer connections and fine. Ten participants means each browser encodes and uploads nine outbound streams, and consumer uplink gives out well before CPU does. Mesh is the right call for small rooms and the wrong call for a classroom — and knowing exactly where that ceiling sits is more useful than pretending it isn't there. Past it, the answer isn't tuning; it's an SFU, and that's a different product.
The Server Owns Room State
Early on I let the room live in the peer mesh: each client tracked who it was connected to, and that was the participant list. This breaks the moment anything goes wrong. Two clients disagree about who's present. A dropped peer connection looks like a departure to one browser and a glitch to another. There's no answer to "who is in this room" — only opinions.
So room state became authoritative on the server. Socket.IO owns it:
- A client emits a join event with a room ID; the socket joins the Socket.IO room.
- The server holds the participant list and broadcasts membership changes.
- Every client renders the server's list, not its own inference from peer connections.
That inversion — the participant list is server state that clients subscribe to, not a byproduct of the mesh — fixed an entire category of bugs at once. Reconnects rebuild cleanly because a reconnecting client asks the server who's present and renegotiates from that, rather than trying to reconcile stale local state.
Disconnects Are Events, Not Timeouts
The lifecycle is event-driven end to end. Socket.IO's disconnect event fires when a transport drops, and that's what removes a participant and notifies the room. No client polls "is everyone still here?"
Polling for presence is the pattern I'm glad I avoided, and the reason is not efficiency. It's ambiguity. A poll that returns nothing can't distinguish "the peer left" from "this response was slow." You end up choosing a timeout, and every timeout is simultaneously too aggressive (kicking people on a subway) and too lenient (ghost participants lingering in the grid).
Transport-level disconnect events are unambiguous about the socket. They are not a complete answer — a browser tab killed hard can leave the socket open briefly, and a socket can survive while the media connection has failed. Which leads to the thing I'd underline for anyone building this: signalling health and media health are different signals. A participant whose socket is fine but whose RTCPeerConnection has gone to failed is present in the room and invisible in the call. Watching connectionstatechange per peer, separately from socket state, is what turns "why can't I see Rahul" into a state the UI can actually show.
What MongoDB Is Actually For
Calls are ephemeral; the record of them isn't. MongoDB stores meeting metadata and participant history — room creation, who joined, when they joined and left, session duration.
Document storage fits because a meeting record is a nested, variable-shaped thing: a room with an embedded array of participation intervals, where the number of participants and the number of join/leave events per participant both vary. Modelling that relationally means a join table and a query to reassemble what is conceptually one object.
The boundary I held to is worth stating: live state lives in memory on the signalling server; historical state lives in MongoDB. Writing every participant event synchronously into the database before broadcasting it would put a database round trip in the middle of the join path — the exact moment a user is staring at a blank tile waiting to see the room. Persistence is a side effect of the event, not a gate on it.
Signalling, Concretely
The join sequence, once room state moved server-side, is small enough to reason about:
- Client connects the socket and emits join with the room ID.
- Server adds it to the room, replies with the current participant list, and broadcasts the arrival.
- For each existing participant, the new client creates a peer connection and sends an SDP offer through the server.
- Each existing participant answers; ICE candidates trickle through the same channel as they're discovered.
- Media connects directly; the server is out of the path.
Two implementation details that are easy to get wrong and expensive to debug:
ICE candidates arrive before you're ready for them. Candidates can show up ahead of the remote description they belong to. Queue them and flush after the description is set, or the connection silently never establishes.
Both sides must not offer simultaneously. Two peers creating offers at once puts the negotiation into a broken state. A deterministic rule — the existing participant offers, the joiner answers — removes the race without needing full perfect-negotiation logic.
What This Project Was For
SAMPARK looks like a WebRTC project. It's really a distributed state project wearing a video call as a costume.
The transferable lessons:
- Decide what the server owns, early. Every consistency bug I hit came from state that was inferred by clients instead of published by the server.
- Prefer events to polling for presence — not for performance, but because events are unambiguous and timeouts never are.
- Track health per layer. Socket up does not imply media up, and a UI that conflates them lies to the user.
- Know your scaling ceiling before you need it. Mesh peer-to-peer is a correct, cheap choice for small rooms and an architectural dead end for large ones, and those are two different sentences about the same design.