All articles

Building WebRTC voice and video into a Go application: TURN relays, mesh topology, and the bugs I shipped

Suman Akkisetty
Founder, KaryoSpace
April 2026
11 min read

Adding real-time voice and video to an enterprise workspace is not optional if you are competing with Teams. So I built it. No Twilio. No Agora. No daily.co. Just WebRTC, a Go signaling server, a coturn TURN relay, and about three weeks of debugging ICE candidates.

The signaling problem

WebRTC handles media peer-to-peer once a connection is established. But establishing that connection requires an out-of-band channel to exchange SDP offers and ICE candidates. That channel is the signaling server, and the choice of transport matters.

I reused the existing WebSocket hub that powers the chat module. The same connection that delivers typing indicators and @mention notifications now carries RTCSessionDescription and RTCIceCandidate payloads. The message type field gates which handler fires.

// Signaling message types (same WS connection as chat)
const (
    WSTypeCallOffer     = "call.offer"
    WSTypeCallAnswer    = "call.answer"
    WSTypeCallIce       = "call.ice"
    WSTypeCallEnd       = "call.end"
    WSTypeCallRing      = "call.ring"
)

The TURN relay problem

Peer-to-peer WebRTC fails silently in most enterprise networks. Corporate NAT, symmetric NAT, and aggressive firewalls block direct UDP connections between peers. STUN (which discovers your public IP) handles around 80% of cases. For the remaining 20% you need a TURN relay, which proxies all media through a server you control.

I deployed coturn on the same Oracle VM. The configuration that matters:

# /etc/turnserver.conf (relevant lines)
listening-port=3478
tls-listening-port=5349
realm=karyospace.com
use-auth-secret
static-auth-secret=YOUR_SECRET_HERE
no-multicast-peers
denied-peer-ip=10.0.0.0-10.255.255.255  # block private range relay
denied-peer-ip=172.16.0.0-172.31.255.255
cipher-list="HIGH"

The no-multicast-peers and denied-peer-ip ranges are not optional. Without them, a malicious peer can use your TURN relay to probe internal network addresses. Always scope TURN to external IPs only.

ICE configuration in the browser

Each call initialises a RTCPeerConnection with both STUN and TURN servers. The browser tries STUN first. If that fails, it falls back to TURN. Both credentials are served from the Go API and never embedded in frontend HTML.

// GET /api/webrtc/ice-config (authenticated)
func iceConfigHandler(w http.ResponseWriter, r *http.Request) {
    expiry := time.Now().Add(1 * time.Hour).Unix()
    user := fmt.Sprintf("%d:%s", expiry, r.Header.Get("X-User-ID"))
    mac := hmac.New(sha256.New, []byte(os.Getenv("TURN_SECRET")))
    mac.Write([]byte(user))
    credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
    json.NewEncoder(w).Encode(ICEConfig{
        IceServers: []ICEServer{
            {URLs: []string{"stun:karyospace.com:3478"}},
            {URLs: []string{"turn:karyospace.com:3478"}, Username: user, Credential: credential},
        },
    })
}

The HMAC-SHA256 credential binds the TURN session to a specific user and expires in one hour. coturn validates this against the same static-auth-secret. A stolen credential is useless after expiry.

Mesh topology for group calls

For 1:1 calls, one peer sends an offer and the other answers. Group calls in KaryoSpace use a full mesh: every participant opens a peer connection to every other participant. No SFU (selective forwarding unit), no MCU (multipoint control unit).

This is the right choice for small groups (under 6 people) and wrong for large groups (10+). For an enterprise workspace where most calls are team standups and pair sessions, mesh is simpler, lower latency, and requires zero server-side media processing. The tradeoff becomes visible above 6 participants when upstream bandwidth saturates.

// When a new peer joins a group channel, the Go server
// sends existing peer IDs to the new joiner
func handleCallJoin(hub *Hub, msg WSMessage) {
    channel := msg.ChannelID
    existingPeers := hub.callPeers[channel]
    hub.callPeers[channel] = append(existingPeers, msg.UserID)

    // New joiner initiates offers to all existing peers
    for _, peerID := range existingPeers {
        hub.sendToUser(msg.UserID, WSMessage{
            Type:   WSTypePeerJoined,
            UserID: peerID,
        })
    }
}

Screen share

Screen share replaces the video track on an existing peer connection. The browser calls getDisplayMedia(), which triggers the OS native screen picker, then replaces the sender track inline. When the user stops sharing (either via the stop button or the browser's built-in stop control), an ended event on the track fires the cleanup handler.

async function startScreenShare() {
    const stream = await navigator.mediaDevices.getDisplayMedia({
        video: { frameRate: 15 },  // 15fps is enough for slides/docs
        audio: false
    });
    const screenTrack = stream.getVideoTracks()[0];

    // Replace video sender on all peer connections
    for (const [peerId, pc] of peerConnections) {
        const sender = pc.getSenders().find(s => s.track?.kind === 'video');
        if (sender) await sender.replaceTrack(screenTrack);
    }

    screenTrack.addEventListener('ended', stopScreenShare);
}

One gotcha: replaceTrack() does not renegotiate the peer connection. It swaps the media inline, which is exactly what you want. Calling createOffer() again would cause a brief black frame and a round trip. Avoid it.

The bugs I shipped

Three bugs made it to production before I caught them:

What it cost to run

coturn on the Oracle Always-Free VM handles the TURN relay at zero marginal cost. For 1:1 calls where STUN succeeds (the majority), the relay is not even used. For the cases where it is, a 30-minute group call with 4 participants burns roughly 800 MB of relay bandwidth at 15fps screen share. That fits comfortably within Oracle's free tier egress limit. The constraint that changes the economics is above 10 simultaneous group calls with screen share active. We are not there yet.

Get in touch

Questions about KaryoSpace or this article? We'd love to hear from you.

Contact Us
We read every submission

Message Sent!

Thanks for reaching out. We'll get back to you shortly.

Send Feedback
We read every submission
Build Stats
Loading stats…