How pion/handoff negotiates WebRTC in the browser then moves the live connection to a remote server, and what the Go code actually looks like.

Start WebRTC in the browser, run it somewhere else — with Go


WebRTC peer connections are stuck to the process that created them. You negotiate in the browser, the browser owns the connection. That’s just how it works.

Except pion/handoff breaks that assumption. It’s a Go project from the Pion team that lets you negotiate a WebRTC session in the browser, then transfer the live connection to a completely different machine. Signaling stays local. Media processing happens somewhere else.

What caught my attention wasn’t the concept itself — it’s how clean the Go code is. The project is a tight example of HTTP signaling, the pion/webrtc API, and goroutine-based connection management. I want to walk through the patterns that make it work.

What problem does handoff solve?

In most WebRTC apps, the process doing SDP negotiation is the same process handling media. Signaling and media processing are welded together.

Handoff breaks that weld. The browser creates an offer, sends it to a local Go server, and that server forwards the SDP to a remote Go server. The remote server creates the real PeerConnection, generates an answer, and sends it back through the chain. From the browser’s perspective, it’s talking to the local server. But media flows to the remote one.

Why bother? Because media processing is heavy. Recording, transcoding, mixing — you want that on a beefy dedicated server, not on whatever box is sitting closest to the user handling signaling.

The signaling flow in Go

Two Go binaries: a local signaling server and a remote media server. The local server is an HTTP handler that takes the browser’s SDP offer, forwards it to the remote server, and returns the answer. That’s it.

Here’s a simplified version of the offer relay:

package main

import (
	"bytes"
	"io"
	"net/http"
)

func main() {
	remoteAddr := "http://remote-server:8080/offer"

	http.HandleFunc("/offer", func(w http.ResponseWriter, r *http.Request) {
		offer, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "failed to read offer", http.StatusBadRequest)
			return
		}

		// Forward the SDP offer to the remote server
		resp, err := http.Post(remoteAddr, "application/sdp", bytes.NewReader(offer))
		if err != nil {
			http.Error(w, "failed to reach remote server", http.StatusBadGateway)
			return
		}
		defer resp.Body.Close()

		answer, err := io.ReadAll(resp.Body)
		if err != nil {
			http.Error(w, "failed to read answer", http.StatusInternalServerError)
			return
		}

		w.Header().Set("Content-Type", "application/sdp")
		w.Write(answer)
	})

	http.ListenAndServe(":9000", nil)
}

No WebSocket. No signaling protocol. Plain HTTP POST with SDP payloads. The local server holds zero WebRTC state.

How the remote server creates the PeerConnection

The remote server does the real work. It receives the SDP offer, creates a PeerConnection via pion/webrtc, sets the remote description, creates an answer, and sends it back.

Condensed version:

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"

	"github.com/pion/webrtc/v4"
)

func main() {
	http.HandleFunc("/offer", func(w http.ResponseWriter, r *http.Request) {
		offerBytes, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}

		var offer webrtc.SessionDescription
		if err := json.Unmarshal(offerBytes, &offer); err != nil {
			http.Error(w, "invalid SDP", http.StatusBadRequest)
			return
		}

		// Create a new PeerConnection
		pc, err := webrtc.NewPeerConnection(webrtc.Configuration{
			ICEServers: []webrtc.ICEServer{
				{URLs: []string{"stun:stun.l.google.com:19302"}},
			},
		})
		if err != nil {
			http.Error(w, "failed to create peer connection", http.StatusInternalServerError)
			return
		}

		// Handle incoming tracks
		pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
			fmt.Printf("Received track: %s (codec: %s)\n",
				track.ID(), track.Codec().MimeType)

			// Read RTP packets in a goroutine
			go func() {
				buf := make([]byte, 1500)
				for {
					n, _, readErr := track.Read(buf)
					if readErr != nil {
						return
					}
					// Process the RTP packet (record, transcode, forward, etc.)
					_ = buf[:n]
				}
			}()
		})

		pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
			fmt.Printf("Connection state: %s\n", state.String())
			if state == webrtc.PeerConnectionStateFailed ||
				state == webrtc.PeerConnectionStateClosed {
				pc.Close()
			}
		})

		if err := pc.SetRemoteDescription(offer); err != nil {
			http.Error(w, "failed to set remote description", http.StatusInternalServerError)
			return
		}

		answer, err := pc.CreateAnswer(nil)
		if err != nil {
			http.Error(w, "failed to create answer", http.StatusInternalServerError)
			return
		}

		if err := pc.SetLocalDescription(answer); err != nil {
			http.Error(w, "failed to set local description", http.StatusInternalServerError)
			return
		}

		// Wait for ICE gathering to complete
		<-webrtc.GatheringCompletePromise(pc)

		response, err := json.Marshal(pc.LocalDescription())
		if err != nil {
			http.Error(w, "failed to marshal answer", http.StatusInternalServerError)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		w.Write(response)
	})

	fmt.Println("Remote server listening on :8080")
	http.ListenAndServe(":8080", nil)
}

A few things I want to point out:

webrtc.GatheringCompletePromise(pc) returns a channel that closes when ICE gathering finishes. Instead of polling or wiring up callbacks, you just block on a channel. It fits Go’s concurrency model perfectly and keeps the HTTP handler looking synchronous to the caller.

OnTrack spawns a goroutine for each incoming media track. Each one reads RTP packets in a tight loop. This is idiomatic Go: one goroutine per stream, communicating through channels or shared state as needed. If you’re unfamiliar with how this works under the hood, understanding goroutines will fill in the gaps.

The PeerConnection outlives the HTTP request. This is the part that trips people up. The handler returns the SDP answer, but the pc variable survives because the OnTrack goroutine holds a reference to it. The garbage collector won’t touch it. Connection cleanup happens in OnConnectionStateChange, not when the handler returns. If you miss this, you’ll leak connections.

ICE candidates and the channel trick

Easy to miss: the code waits for ICE gathering to finish before sending the answer. This is “trickle ICE disabled” — all ICE candidates get embedded directly in the SDP.

The GatheringCompletePromise function from pion/webrtc looks roughly like this internally:

func GatheringCompletePromise(pc *PeerConnection) <-chan struct{} {
	done := make(chan struct{})
	pc.OnICEGatheringStateChange(func(state ICEGathererState) {
		if state == ICEGathererStateComplete {
			close(done)
		}
	})
	return done
}

It returns a receive-only channel. The caller blocks with <- until the channel closes. If you’ve worked with context in Go, you’ve seen the same idea with ctx.Done(). It’s a really effective pattern for turning callback-based APIs into sequential code.

Why Go fits this well

A few practical reasons Go works here:

Static binaries. Both servers compile to single binaries with no runtime dependencies. Drop them on a Linux box and run. That matters when you’re deploying a signaling proxy on one machine and a media server on another.

Goroutines are cheap. Each WebRTC connection spawns several goroutines for track reading, connection state monitoring, and ICE management. In a language with OS threads, this gets expensive fast. Go handles thousands of concurrent connections without you having to think much about it.

The standard library is enough. The signaling server is a few http.HandleFunc calls. No framework, no dependency tree to manage, no abstraction layers to learn. If you’re curious about when frameworks actually earn their weight in Go, I wrote about when to use a framework in Go.

Running it yourself

Clone the repo. The local and remote servers live in separate directories:

git clone https://github.com/pion/handoff.git
cd handoff

# Start the remote server (media handler)
go run ./remote/main.go

# In another terminal, start the local server (signaling proxy)
go run ./local/main.go

Open the included HTML page in your browser. It negotiates a WebRTC connection through the local server, but media actually flows to the remote one. Watching the remote server’s logs while streaming from your webcam makes the split obvious.

Where this pattern goes next

Handoff is a small project, but the architecture it demonstrates shows up in production WebRTC systems all the time. Separating signaling from media handling gives you flexibility to scale them independently, deploy them in different regions, or swap out the media backend without touching the client.

The Go code itself is worth studying even if you never use handoff directly. HTTP handlers as signaling endpoints, channels for synchronization, goroutines for per-track processing — these are the same building blocks you’ll reach for whether you’re building a conferencing system or a media recording pipeline.