DEV Community

AiLaoHuYu
AiLaoHuYu

Posted on

What actually breaks when you build a WebRTC SFU in Go

Every Pion example I found stops at "hello world": two peer connections, one track, it works, the blog post ends. That is the easy 20%. The other 80% is what happens after — when the publisher's connection drops, when a viewer joins mid-stream, when a reverse proxy reaps your signaling socket, when the person streaming switches browser tabs.

I built a low-latency streaming server: one Go binary doing WebSocket signaling plus SFU forwarding, with a browser publisher, viewer and diagnostics panel. These are the nine things that actually cost me time. Symptoms first, because that is what you will be searching for at 2am.

Spoiler for the whole article: most of these look like a media problem and are not one.

1. TrackRemote.WriteRTCP is gone in Pion v4

Symptom: remote.WriteRTCP undefined (type *webrtc.TrackRemote has no field or method WriteRTCP) after upgrading from v3.

Cause: v4 moved RTCP writing onto the PeerConnection. A track can only read.

Fix: keep the publisher's *webrtc.PeerConnection next to the track and call pubPC.WriteRTCP(pkts) — and rewrite the target SSRC first, because viewer feedback (PLI / FIR / NACK) references the viewer's SSRC, not the publisher's:

for _, p := range pkts {
    switch pkt := p.(type) {
    case *rtcp.PictureLossIndication: pkt.MediaSSRC = uint32(t.remote.SSRC())
    case *rtcp.FullIntraRequest:      pkt.MediaSSRC = uint32(t.remote.SSRC())
    case *rtcp.TransportLayerNack:    pkt.MediaSSRC = uint32(t.remote.SSRC())
    }
}
Enter fullscreen mode Exit fullscreen mode

Get that SSRC rewrite wrong and you get the worst kind of bug: RTCP flows, nothing errors, and the publisher just never sends you a keyframe.

2. event.streams is empty in ontrack

Symptom: the viewer's peer connection reports inbound-rtp bytes and a resolution, ICE is connected, getStats() looks perfect — and the video element stays black.

Cause: video.srcObject = e.streams[0] where e.streams is an empty array assigns undefined. Nothing throws. It looks like a media failure and it is a UI bug.

Fix: fall back to a stream built from the track itself:

p.ontrack = (e) => {
  const ms = (e.streams && e.streams[0]) || new MediaStream([e.track]);
  video.srcObject = ms;
};
Enter fullscreen mode Exit fullscreen mode

This one is nasty precisely because the statistics are good. Every number says the stream is arriving. Only video.srcObject knows the truth, and nothing queries it.

3. Stale JavaScript after every edit

Symptom: you change viewer.js, reload, and the old behaviour is still there. Hard reload fixes it. Then it comes back.

Cause: http.FileServer sends Last-Modified but no Cache-Control, so the browser applies heuristic caching and serves the script from memory.

Fix: wrap the static handler and send Cache-Control: no-cache so the browser always revalidates. Cheap — 304s carry no body.

The reason this is worth its own entry: it makes you doubt your own fixes. You "verify" a change, see the old behaviour, and go looking for a bug that is not there.

4. Forwarding RTP without rewriting payload types

Symptom: the viewer's SDP negotiates fine, but Chrome drops every packet or shows a black frame, and the server logs a codec the viewer never agreed to.

Cause: the server's default MediaEngine has its own payload-type table. If the publisher sends VP8 on PT 96 and the viewer side re-offers it as PT 100, the RTP you forward blindly is garbage for the viewer — the payload type is a number, and the number is per-connection.

Fix: build the viewer-side PeerConnection from a MediaEngine that registers the publisher's own codec parameters, including its payload type:

me := &webrtc.MediaEngine{}
for _, t := range tracks {
    me.RegisterCodec(t.remote.Codec(), t.remote.Kind())
}
api := webrtc.NewAPI(webrtc.WithMediaEngine(me))
Enter fullscreen mode Exit fullscreen mode

Then track which codecs that PC was built for. If the publisher adds a codec the PC does not know, rebuild the PC rather than trying to renegotiate it in place. Trying to be clever here is how you get an intermittent bug that only shows up when someone publishes screen share after camera.

5. ICE candidates that arrive before the remote description

Symptom: AddICECandidate: InvalidStateError, and connections that only come up on the second try.

Cause: onicecandidate on the client fires before its SDP has reached the server, so trickled candidates land while the server still has no remote description to attach them to.

Fix: buffer candidates per client until SetRemoteDescription has been applied, then flush them. It is about fifteen lines and it removes an entire class of "sometimes it doesn't connect".

6. Republishing stacks state

Symptom: after the publisher's connection fails and it republishes, viewers see two video tracks — or the picture only updates every other attempt.

Cause: the publisher's websocket survived, so the server kept its old PeerConnection, its old track list and its old viewer senders, and merged the new session into them. From the server's point of view this is not a new publisher at all.

Fix: if a second offer arrives on a session whose remote description is already set, treat it as a republish. Close the server-side PC, clear the room's tracks, tear down every viewer PC, broadcast publisher{live:false}, then negotiate from scratch. Viewers must also drop their PeerConnection on live:false — otherwise they hold a dead one and autoplay silently stops.

This is the bug that taught me the difference between "the socket is alive" and "the session is alive". They are not the same thing.

7. A backgrounded tab stops the source

Symptom: the self-test page reports running… forever — no frames, no bitrate — but only when you switch to another tab. Coming back "fixes" it.

Cause: the canvas source was drawn from requestAnimationFrame, which browsers stop entirely for hidden documents. No drawing means captureStream() has no frames to emit, so the publisher sends nothing. It looks like a media failure. It is not one.

Fix: drive the source from a timer (setInterval) instead of rAF, and decide pass/fail from framesDecoded in getStats() rather than from requestVideoFrameCallback — which is also rendering-dependent. A backgrounded tab paints nothing but still decodes.

This one is worth internalising beyond the test page: a publisher's tab is usually backgrounded. Anything you build on rAF will stop working the moment your user switches to another window, which is exactly when they are streaming.

8. Renegotiating once per track

Symptom: viewers receive one offer per track when a publisher starts audio + video, and browsers answer them out of order.

Cause: OnTrack fires once per track. Negotiating inline races.

Fix: debounce. One time.AfterFunc(250ms) per room, cancelled and restarted on each new track, that renegotiates every viewer once. Serialize viewer-side SDP handling in the browser with a promise chain too, or the same race reappears on the other end.

9. Reverse proxies drop the idle signaling socket

Symptom: everything works locally, but behind Cloudflare or nginx a long session suddenly reconnects — or a publisher's socket dies mid-stream and comes back as a second publisher (see #6).

Cause: signaling is a flurry of messages at connect time and then goes completely silent for as long as media flows. Reverse proxies close idle WebSockets on a timer — Cloudflare around 100s, nginx's proxy_read_timeout 60s by default. Nothing is wrong with the media path. The signaling socket just gets reaped.

Fix: let the server generate the traffic, and require a pong. The instinct is a JS setInterval heartbeat on the client, but browsers cannot send protocol-level ping frames from JS, and timers get throttled in a background tab — which is exactly where a publisher's tab sits:

// writeLoop
ticker := time.NewTicker(25 * time.Second)
c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second))
Enter fullscreen mode Exit fullscreen mode
// readLoop
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
    return c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
})
Enter fullscreen mode Exit fullscreen mode

Protocol pings are answered by the browser itself, so this works even in a hidden tab.

It also buys you liveness detection for free: a peer that stops ponging is dropped within 60s and releases the room, instead of leaving it stuck on "room busy" after someone closes their laptop. A reconnecting publisher then looks like a second publisher — which is what #6 defends against. That is not a coincidence; that is the actual sequence of bugs you hit, in order.

The pattern worth taking away

Five of the nine present as "the video is broken". Two of those are not media bugs at all (#2 is a UI assignment, #7 is rAF being paused). The rest are lifecycle bugs: a socket that outlives its session, a candidate that arrives before the description it belongs to, a payload type that means different things on different connections.

WebRTC gives you excellent statistics and no error messages. So the instinct is to go straight to getStats() — and it will happily tell you that everything is fine while the user stares at a black rectangle. Check the boring layer first. Is srcObject set? Is the tab visible? Did the socket get reaped? Those three questions would have saved me most of the time these nine bugs cost.

The kit

I put the result of all this into a starter kit — the signaling server and SFU (Go, Pion v4), the browser publisher and viewer, the auto-reconnect chain, the diagnostics panel, the deploy files for systemd / Caddy / Docker and the no-domain-tunnel route, plus the full pitfalls doc with symptom → cause → fix for each entry above.

There is a live demo you can open without an account, and a canvas self-test page that exercises the whole path and needs no camera: https://lowlatencykit.com

It is paid (early access, Basic $99 / Pro $199 / Team $499, with an early-bird code on the landing page). The pitfalls above are free, and always will be — that part is not a teaser, it is the actual content.

Top comments (0)