A minimal real-time video conferencing system built with Go and WebRTC using an SFU (Selective Forwarding Unit) architecture.
- WebRTC video + audio streaming
- SFU (Selective Forwarding Unit) implementation
- Multi-peer support (any number of participants)
- Real-time signaling over WebSockets
- Dynamic peer joins/leaves with automatic renegotiation
- Multi-room support
- Active speaker detection
Clients send media to the server, which forwards it to all other participants.
A → Server → B
→ C
→ D
Each client maintains a single WebRTC connection to the server, regardless of participant count.
- Go
- WebSockets (gorilla/websocket)
- WebRTC (pion/webrtc)
- HTML + Vanilla JS client
internal/
client/ # WebSocket client abstraction
sfu/ # SFU logic (tracks, fanout, peers)
ws/ # WebSocket handler + signaling
cmd/
server/ # Entry point
example/
index.html # Test client
realtime-edge.js # JavaScript client SDK
The JavaScript client (example/realtime-edge.js) provides a high-level API for WebRTC video conferencing.
const client = new RealtimeEdgeClient({ wsUrl, roomId });Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
wsUrl |
string | Yes | WebSocket server URL (e.g., ws://localhost:3000/ws) |
roomId |
string | Yes | Room identifier for the conference |
Initiates connection to the signaling server and joins the specified room. Automatically captures local media (video + audio).
await client.join();Disconnects from the room and closes all WebRTC connections.
client.leave();Toggles the local microphone on/off.
const enabled = client.toggleMic();Returns: boolean - true if microphone is now enabled, false if disabled.
Toggles the local camera on/off.
const enabled = client.toggleCamera();Returns: boolean - true if camera is now enabled, false if disabled.
Returns the local media stream.
const stream = client.getLocalStream();Returns: MediaStream | null - The local video/audio stream.
Registers an event listener.
client.on("participantJoined", ({ streamId, stream, displayName }) => {
// Handle new participant
});Removes an event listener.
client.off("participantJoined", myHandler);Fired when the local media stream is captured.
client.on("localStream", ({ stream }) => {
localVideo.srcObject = stream;
});Payload:
| Property | Type | Description |
|---|---|---|
stream |
MediaStream | The local media stream |
Fired when a new remote participant joins the room.
client.on("participantJoined", ({ streamId, stream, displayName }) => {
addRemoteVideo(streamId, stream, displayName);
});Payload:
| Property | Type | Description |
|---|---|---|
streamId |
string | Unique identifier for the participant's stream |
stream |
MediaStream | The participant's media stream |
displayName |
string | Human-readable name for display |
Fired when a new media track is received from a remote participant.
client.on("trackAdded", ({ streamId, track, stream, displayName }) => {
// Handle incoming track
});Payload:
| Property | Type | Description |
|---|---|---|
streamId |
string | Unique identifier for the stream |
track |
MediaStreamTrack | The incoming media track |
stream |
MediaStream | The media stream containing the track |
displayName |
string | Human-readable name for display |
Fired when a media track is removed from a remote participant.
client.on("trackRemoved", ({ streamId, track }) => {
// Handle track removal
});Payload:
| Property | Type | Description |
|---|---|---|
streamId |
string | Stream identifier |
track |
MediaStreamTrack | The removed media track |
Fired when a remote participant leaves the room.
client.on("participantLeft", ({ streamId }) => {
removeRemoteVideo(streamId);
});Payload:
| Property | Type | Description |
|---|---|---|
streamId |
string | ID of the departing participant |
Fired when the WebSocket signaling connection state changes.
client.on("signalingStateChanged", ({ state }) => {
console.log("Signaling state:", state);
});Payload:
| Property | Type | Description |
|---|---|---|
state |
string | Connection state: "connected", "disconnected", "error", "left" |
Fired when the WebRTC PeerConnection state changes.
client.on("connectionStateChanged", ({ peerId, state }) => {
console.log(`Peer ${peerId}: ${state}`);
});Payload:
| Property | Type | Description |
|---|---|---|
peerId |
string | Peer identifier (always "server" for SFU mode) |
state |
string | Connection state: "connecting", "connected", "disconnected", "failed", "closed" |
Fired when the local microphone state changes (via toggleMic()).
client.on("micStateChanged", ({ enabled }) => {
micBtn.innerText = enabled ? "🎤 Mic On" : "🎤 Mic Off";
});Payload:
| Property | Type | Description |
|---|---|---|
enabled |
boolean | true if mic is on, false if off |
Fired when the local camera state changes (via toggleCamera()).
client.on("cameraStateChanged", ({ enabled }) => {
camBtn.innerText = enabled ? "📷 Camera On" : "📷 Camera Off";
});Payload:
| Property | Type | Description |
|---|---|---|
enabled |
boolean | true if camera is on, false if off |
Fired when the active speaker changes (or clears during silence).
client.on(
"activeSpeakerChanged",
({ streamId, previousStreamId, displayName, level, isLocal }) => {
// streamId is "local", a remote streamId, or null
},
);Payload:
| Property | Type | Description |
|---|---|---|
streamId |
string | null | Active speaker stream ID ("local" for self, or null when no active speaker) |
previousStreamId |
string | null | Previously active speaker stream ID |
displayName |
string | null | Display label for the active speaker |
level |
number | Current normalized audio level |
isLocal |
boolean | true when the active speaker is the local participant |
The server uses JSON messages over WebSocket for signaling.
Join a room and initiate WebRTC connection.
{
"type": "join",
"roomId": "room-1",
"data": "<SDP offer as JSON string>"
}Leave the current room.
{
"type": "leave"
}Send an ICE candidate to the server.
{
"type": "ice",
"data": "<ICE candidate as JSON string>"
}Send a WebRTC answer (for re-negotiation).
{
"type": "answer",
"data": "<SDP answer as JSON string>"
}WebRTC answer from server.
{
"type": "event",
"event": "answer",
"data": "<SDP answer as JSON string>"
}Acknowledgment response.
{
"type": "ack",
"message": "left room"
}Error message.
{
"type": "error",
"message": "error description"
}Main HTTP server that handles WebSocket connections and serves static files.
File: internal/server/server.go
Creates a new server instance.
Starts the HTTP server on port 3000.
Routes:
| Path | Handler | Description |
|---|---|---|
/ |
Static file server | Serves example/ directory |
/health |
Health check | Returns "ok" |
/ws |
WebSocket handler | WebRTC signaling |
WebSocket handler for WebRTC signaling.
File: internal/ws/handler.go
Creates a new WebSocket handler with an SFU manager.
HTTP handler that upgrades the connection to WebSocket.
WebSocket message structure.
File: internal/ws/message.go
| Type | Value | Description |
|---|---|---|
Join |
"join" |
Join a room |
Leave |
"leave" |
Leave a room |
Offer |
"offer" |
WebRTC offer (reserved for future) |
Answer |
"answer" |
WebRTC answer |
ICE |
"ice" |
ICE candidate |
| Type | Value | Description |
|---|---|---|
Ack |
"ack" |
Acknowledgment |
Error |
"error" |
Error response |
Event |
"event" |
Event notification |
Room manager that handles multiple rooms.
File: internal/sfu/manager.go
Creates a new SFU manager.
Gets or creates a room by ID.
Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Room identifier |
Returns: *Room - The room instance.
Represents a conference room with participants and media tracks.
File: internal/sfu/room.go
Adds a peer to the room and subscribes them to existing tracks.
Removes a peer from the room and cleans up their tracks.
Gets a peer by ID.
Returns the number of peers in the room.
Checks if there are tracks from other peers (used for negotiation).
Represents a participant's WebRTC connection.
File: internal/sfu/room.go
Closes the peer connection.
Initiates or responds to WebRTC negotiation.
Triggers deferred negotiation if needed.
Manages media track distribution to subscribers.
File: internal/sfu/room.go
Adds a subscriber to receive this track.
Sends keyframe requests to reduce startup delay for new subscribers.
Forwards RTP packets to all active subscribers.
WebSocket client wrapper.
File: internal/client/client.go
| Field | Type | Description |
|---|---|---|
ID |
string | Unique client ID |
Conn |
*websocket.Conn |
WebSocket connection |
Send |
chan []byte | Send channel |
RoomID |
string | Current room ID |
Peer |
*pion.PeerConnection |
WebRTC connection |
Closes the send channel.
Non-blocking send attempt.
Reads messages from WebSocket and calls callbacks.
c.ReadPump(func(msg []byte) {
// handle message
}, func() {
// handle close
})Writes messages to WebSocket from the send channel.
go run ./cmd/serverOpen in browser:
http://localhost:3000
Open multiple tabs to simulate multiple users.
// Initialize client
const client = new RealtimeEdgeClient({
wsUrl: "ws://localhost:3000/ws",
roomId: "my-room",
});
// Register event handlers
client.on("localStream", ({ stream }) => {
document.getElementById("localVideo").srcObject = stream;
});
client.on("participantJoined", ({ streamId, stream, displayName }) => {
console.log(`${displayName} joined with stream ${streamId}`);
// Add video element for new participant
});
client.on("participantLeft", ({ streamId }) => {
console.log(`Stream ${streamId} left`);
// Remove video element
});
client.on("connectionStateChanged", ({ state }) => {
console.log("Connection state:", state);
});
// Join the room
client.join();
// Toggle media controls
document.getElementById("micBtn").onclick = () => client.toggleMic();
document.getElementById("camBtn").onclick = () => client.toggleCamera();
document.getElementById("leaveBtn").onclick = () => client.leave();- No authentication
- No TURN server (may fail behind strict NAT)
- No simulcast / bitrate control
- Basic UI
- Single-node (no horizontal scaling)
- Better UI (video grid, controls)
- Screen sharing
- TURN/STUN configuration