Skip to main content
The broadcast example demonstrates how to implement a Selective Forwarding Unit (SFU) pattern where one broadcaster uploads video once, and the server forwards it to multiple viewers. This is essential for building scalable video streaming applications, webinars, or live broadcasting platforms.

Overview

This example creates a simple SFU (Selective Forwarding Unit) that accepts one video stream from a broadcaster and distributes it to multiple viewers. The broadcaster only uploads once, dramatically reducing bandwidth requirements compared to peer-to-peer mesh architectures.

Key Features

  • One-to-many video distribution
  • Minimal bandwidth usage for broadcaster
  • HTTP server for easy SDP exchange
  • Automatic track forwarding to all connected viewers
  • Support for unlimited viewers
  • Interval PLI for keyframe management

Architecture

Broadcaster → [Server/SFU] → Viewer 1

                 Viewer 2

                 Viewer 3

                   ...

How It Works

1

Start HTTP Server

The example starts an HTTP server to receive SDP offers:
func httpSDPServer(port int) chan string {
    sdpChan := make(chan string)
    http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
        body, _ := io.ReadAll(req.Body)
        fmt.Fprintf(res, "done")
        sdpChan <- string(body)
    })

    go func() {
        panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
    }()

    return sdpChan
}
2

Accept Broadcaster Connection

Create the first PeerConnection to receive the broadcast:
peerConnection, err := webrtc.NewAPI(
    webrtc.WithMediaEngine(mediaEngine),
    webrtc.WithInterceptorRegistry(interceptorRegistry),
).NewPeerConnection(peerConnectionConfig)

// Allow us to receive 1 video track
if _, err = peerConnection.AddTransceiverFromKind(
    webrtc.RTPCodecTypeVideo,
); err != nil {
    panic(err)
}
3

Create Local Track for Distribution

When receiving the remote track, create a local track to forward to viewers:
localTrackChan := make(chan *webrtc.TrackLocalStaticRTP)

peerConnection.OnTrack(func(
    remoteTrack *webrtc.TrackRemote,
    receiver *webrtc.RTPReceiver,
) {
    // Create a local track for distribution
    localTrack, err := webrtc.NewTrackLocalStaticRTP(
        remoteTrack.Codec().RTPCodecCapability,
        "video",
        "pion",
    )
    if err != nil {
        panic(err)
    }
    localTrackChan <- localTrack

    // Forward all packets from remote to local
    rtpBuf := make([]byte, 1400)
    for {
        i, _, readErr := remoteTrack.Read(rtpBuf)
        if readErr != nil {
            panic(readErr)
        }

        // Write to local track (forwards to all viewers)
        if _, err = localTrack.Write(rtpBuf[:i]); err != nil &&
           !errors.Is(err, io.ErrClosedPipe) {
            panic(err)
        }
    }
})
4

Add Viewers

For each viewer, create a new PeerConnection and add the local track:
for {
    fmt.Println("Curl an base64 SDP to start sendonly peer connection")

    recvOnlyOffer := webrtc.SessionDescription{}
    decode(<-sdpChan, &recvOnlyOffer)

    // Create a new PeerConnection for this viewer
    peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfig)
    if err != nil {
        panic(err)
    }

    // Add the broadcast track to this viewer
    rtpSender, err := peerConnection.AddTrack(localTrack)
    if err != nil {
        panic(err)
    }

    // Read incoming RTCP packets
    go func() {
        rtcpBuf := make([]byte, 1500)
        for {
            if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
                return
            }
        }
    }()

    // Complete connection setup (create answer, etc.)
    // ...
}

Complete Source Code

package main

import (
    "encoding/base64"
    "encoding/json"
    "errors"
    "flag"
    "fmt"
    "io"
    "net/http"
    "strconv"

    "github.com/pion/interceptor"
    "github.com/pion/interceptor/pkg/intervalpli"
    "github.com/pion/webrtc/v4"
)

func main() {
    port := flag.Int("port", 8080, "http server port")
    flag.Parse()

    sdpChan := httpSDPServer(*port)

    // Get broadcaster's offer
    offer := webrtc.SessionDescription{}
    decode(<-sdpChan, &offer)
    fmt.Println("")

    peerConnectionConfig := webrtc.Configuration{
        ICEServers: []webrtc.ICEServer{
            {
                URLs: []string{"stun:stun.l.google.com:19302"},
            },
        },
    }

    mediaEngine := &webrtc.MediaEngine{}
    if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
        panic(err)
    }

    // Create InterceptorRegistry
    interceptorRegistry := &interceptor.Registry{}

    // Use default interceptors
    if err := webrtc.RegisterDefaultInterceptors(
        mediaEngine,
        interceptorRegistry,
    ); err != nil {
        panic(err)
    }

    // Register interval PLI interceptor
    intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
    if err != nil {
        panic(err)
    }
    interceptorRegistry.Add(intervalPliFactory)

    // Create PeerConnection for broadcaster
    peerConnection, err := webrtc.NewAPI(
        webrtc.WithMediaEngine(mediaEngine),
        webrtc.WithInterceptorRegistry(interceptorRegistry),
    ).NewPeerConnection(peerConnectionConfig)
    if err != nil {
        panic(err)
    }
    defer peerConnection.Close()

    // Allow receiving 1 video track
    if _, err = peerConnection.AddTransceiverFromKind(
        webrtc.RTPCodecTypeVideo,
    ); err != nil {
        panic(err)
    }

    localTrackChan := make(chan *webrtc.TrackLocalStaticRTP)

    // Handle incoming track from broadcaster
    peerConnection.OnTrack(func(
        remoteTrack *webrtc.TrackRemote,
        receiver *webrtc.RTPReceiver,
    ) {
        // Create local track for distribution
        localTrack, err := webrtc.NewTrackLocalStaticRTP(
            remoteTrack.Codec().RTPCodecCapability,
            "video",
            "pion",
        )
        if err != nil {
            panic(err)
        }
        localTrackChan <- localTrack

        rtpBuf := make([]byte, 1400)
        for {
            i, _, readErr := remoteTrack.Read(rtpBuf)
            if readErr != nil {
                panic(readErr)
            }

            // Forward to all connected viewers
            if _, err = localTrack.Write(rtpBuf[:i]); err != nil &&
               !errors.Is(err, io.ErrClosedPipe) {
                panic(err)
            }
        }
    })

    // Set remote description and create answer for broadcaster
    err = peerConnection.SetRemoteDescription(offer)
    if err != nil {
        panic(err)
    }

    answer, err := peerConnection.CreateAnswer(nil)
    if err != nil {
        panic(err)
    }

    gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
    err = peerConnection.SetLocalDescription(answer)
    if err != nil {
        panic(err)
    }
    <-gatherComplete

    fmt.Println(encode(peerConnection.LocalDescription()))

    localTrack := <-localTrackChan

    // Accept viewer connections
    for {
        fmt.Println("")
        fmt.Println("Curl an base64 SDP to start sendonly peer connection")

        recvOnlyOffer := webrtc.SessionDescription{}
        decode(<-sdpChan, &recvOnlyOffer)

        // Create PeerConnection for viewer
        peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfig)
        if err != nil {
            panic(err)
        }

        rtpSender, err := peerConnection.AddTrack(localTrack)
        if err != nil {
            panic(err)
        }

        // Read RTCP packets
        go func() {
            rtcpBuf := make([]byte, 1500)
            for {
                if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
                    return
                }
            }
        }()

        err = peerConnection.SetRemoteDescription(recvOnlyOffer)
        if err != nil {
            panic(err)
        }

        answer, err := peerConnection.CreateAnswer(nil)
        if err != nil {
            panic(err)
        }

        gatherComplete = webrtc.GatheringCompletePromise(peerConnection)
        err = peerConnection.SetLocalDescription(answer)
        if err != nil {
            panic(err)
        }
        <-gatherComplete

        fmt.Println(encode(peerConnection.LocalDescription()))
    }
}

Important Implementation Details

The example uses TrackLocalStaticRTP to forward packets:
localTrack, err := webrtc.NewTrackLocalStaticRTP(
    remoteTrack.Codec().RTPCodecCapability,
    "video",
    "pion",
)
Why StaticRTP?
  • Direct packet forwarding without re-encoding
  • Low CPU usage
  • Preserves original quality
  • No transcoding overhead
The same local track instance is added to all viewer PeerConnections.
When no viewers are connected, writing to the local track returns io.ErrClosedPipe:
if _, err = localTrack.Write(rtpBuf[:i]); err != nil &&
   !errors.Is(err, io.ErrClosedPipe) {
    panic(err)
}
This is expected and should not cause the application to panic. Once viewers connect, packets will be successfully delivered.
This example uses HTTP POST for signaling:
# Send SDP offer
curl -X POST -d "<base64-encoded-sdp>" http://localhost:8080
For production, implement:
  • WebSocket signaling for bidirectional communication
  • Authentication and authorization
  • Session management
  • Proper error handling
Current implementation:
  • Single server process
  • Unlimited viewers (limited by server resources)
  • No load balancing
For production scale:
  • Implement clustering with multiple SFU servers
  • Use cascading SFUs for geographic distribution
  • Add bandwidth estimation and adaptive bitrate
  • Monitor server resources and connection health
  • Consider using established SFU solutions like Pion SFU

Running the Example

1

Start the server

cd examples/broadcast
go run main.go --port 8080
2

Connect broadcaster

  1. Open the broadcast page in a browser
  2. Enable your webcam
  3. Generate and copy the offer
  4. Send it via curl:
curl -X POST -d "<base64-sdp-offer>" http://localhost:8080
  1. Copy the answer from terminal and paste in browser
3

Connect viewers

For each viewer:
  1. Open the viewer page in a new browser/tab
  2. Generate and copy the offer
  3. Send via curl (same command as broadcaster)
  4. Copy the answer and paste in browser
  5. Video should start playing
4

Add more viewers

Repeat step 3 for as many viewers as needed. Each viewer receives the same broadcast stream.

Bandwidth Comparison

Mesh (P2P)

For 10 viewers:
  • Broadcaster upload: 10x bandwidth
  • Each viewer: 1x bandwidth
  • Total: 20x bandwidth

SFU (This Example)

For 10 viewers:
  • Broadcaster upload: 1x bandwidth
  • Server processing: Packet forwarding only
  • Total: 11x bandwidth (10x viewer download + 1x upload)
The SFU pattern dramatically reduces broadcaster bandwidth requirements. With 100 viewers, mesh would require 100x upload bandwidth, while SFU still only requires 1x!

Use Cases

  • Live streaming events and webinars
  • Video conferencing (with modifications for multi-publisher)
  • Online education and virtual classrooms
  • Live sports broadcasting
  • Concert and performance streaming

Build docs developers (and LLMs) love