Build Device-to-Browser Video Streaming with WebRTC and rstream

Build a low-latency Go-based device-side streamer that publishes its viewer through rstream tunnels, uses QUIC for the producer tunnel transport, Trickle ICE, managed STUN/TURN, and packages cleanly for Linux devices.


This guide is the first part of a series about browser-facing video delivery from remote devices. In this first step, the device runs a single Go binary that captures video with GStreamer, serves its own viewer, handles signaling, bootstraps TURN, and sends media with Pion. rstream tunnels is what turns that local process into a browser-facing service. It gives the device a public URL, applies access policy at the edge, and provides the STUN and TURN data required to establish the WebRTC session.

This repository is intentionally practical rather than minimal. It already includes the pieces that matter once a device leaves the lab, including H.264 and AV1 reference profiles, shared or per-viewer media allocation, Trickle ICE, congestion feedback, lost-packet recovery, adaptive bitrate, tunnel reconnection, and Linux distribution builds that produce a standalone binary. The code also handles the failure modes that show up quickly on real devices, including tunnel disconnects, media-pipeline failures, and orderly teardown when a viewer or pipeline stops. In practice, that means one process can run on a remote or embedded device and expose a low-latency stream to any browser that can reach the tunnel URL, without requiring a dedicated signaling service, TURN deployment, or custom edge infrastructure for the first version.

Treat this code as a base to adapt rather than throwaway example code. The capture source, encoder settings, auth mode, viewer limits, and deployment shape are all meant to change with the actual device and network you plan to run. The GStreamer path is one concrete way to produce video on the device, not a requirement for every deployment.

The repository is available here.

rstream examples / webrtc-video-streamingOpen the standalone WebRTC video streaming sample.
git clone https://github.com/rstreamlabs/rstream-examples.git
cd rstream-examples/webrtc-video-streaming

Scope

This first guide keeps the whole flow inside the device process. For development, diagnostics, lab work, and smaller single-device deployments, that shape is often enough on its own. The later guides build on the same streaming code running on the device and add the layers that matter when the same pattern has to scale across multiple devices and multiple viewers.

This guide covers a direct device-to-browser WebRTC path, publication through rstream HTTP tunnels, tunnel auth flags at the edge, QUIC as the producer-to-rstream tunnel transport, managed STUN and TURN integration, Trickle ICE and ICE restart for network changes, H.264 as the reference codec and AV1 as an opt-in path, congestion feedback and lost-packet recovery, a first adaptive bitrate implementation, and static Linux builds for remote devices.

The next guide, Build a Next.js WebRTC Video Platform with rstream, keeps the same device-side streaming code and adds a Next.js backend for device inventory, producer provisioning, TURN issuance, and viewer authorization.

Prepare the project and local context

Before running the sample, install the rstream CLI, create an account, create a project, and select that project locally. The installation and login details are covered in Installation, CLI Login, and CLI Workflow. Once the project exists, select it locally with this setup.

rstream login
rstream project use <project-endpoint> --default

The sample reads the active rstream context from the local machine. That context is used both to publish the tunnel and to obtain the TURN material required by the WebRTC path.

For local development, the machine also needs Go 1.26.3+, Node.js 20+, pkg-config, and a GStreamer installation that includes the elements required by the selected pipeline. The sample README includes the package-level setup for macOS and Ubuntu/Debian.

Architecture

The device-side process keeps everything on one origin. The local HTTP server serves the viewer page together with the signaling, TURN bootstrap, and status endpoints. The GStreamer pipeline produces H.264 or AV1 access units. Pion sends those access units over WebRTC. rstream publishes that local HTTP server through a public tunnel so the browser can reach the whole flow through one stable entrypoint. The result is straightforward to operate. Run one process on the device, get one public URL back, and reach the stream from anywhere without standing up separate edge infrastructure for publication, signaling, or TURN.

The library choices follow the same logic. Pion is the reference WebRTC implementation in the Go ecosystem. It is stable, modular, and already exposes the transport feedback, interceptor model, and media primitives needed for a real device-side streamer. GStreamer covers the media side of the stack. It gives the device a flexible way to describe capture, conversion, and encoding as a pipeline string instead of hard-coding one specific source graph in Go.

On paper, combining a Go codebase with a C-heavy media stack looks less attractive than a pure Go path. In practice, the build chain used in this repository keeps that tradeoff manageable. gstreamer-full, static linking, cgo, and musl make it possible to package the whole application as a standalone Linux binary without giving up the flexibility of GStreamer during development. That is one of the reasons this architecture works well for remote and embedded systems.

From the browser side, the flow stays simple. The page loads from the tunnel URL, opens signaling on the same origin, asks the device process for TURN credentials, and receives media from that same process. On the device, the operational model stays simple too. There is still only one binary to start and one local listener to expose.

Use the Go SDK

The part to focus on in the code is small. The device process reads the active local rstream context, opens a published HTTP tunnel with the Go SDK, and then serves its local HTTP handler through that tunnel. The important point is that the tunnel integrates cleanly with standard Go networking interfaces. It does not force a separate serving model for the public path.

client, err := newRstreamClient(opts, cfg.Tunnel.Transport.UseQUIC)
if err != nil {
	return nil, err
}
control, err := client.Connect(ctx, nil)
if err != nil {
	return nil, fmt.Errorf("connect to rstream tunnel engine: %w", err)
}
auth := cfg.Tunnel.Auth
name := strings.TrimSpace(cfg.Tunnel.Name)
properties := rstream.TunnelProperties{
	Name:        rstream.StringPtr(name),
	Publish:     rstream.BoolPtr(true),
	Protocol:    rstream.ProtocolPtr(rstream.ProtocolHTTP),
	HTTPVersion: rstream.HTTPVersionPtr(rstream.HTTP1_1),
}
if auth.Token {
	properties.TokenAuth = rstream.BoolPtr(true)
}
if auth.Rstream {
	properties.RstreamAuth = rstream.BoolPtr(true)
}
rawTunnel, err := control.CreateTunnel(ctx, properties)
if err != nil {
	_ = control.Close()
	return nil, fmt.Errorf("create published HTTP tunnel: %w", err)
}

useQuic is the tunnel transport used between the producer and the rstream engine. The public service is still an HTTP tunnel for the browser, the signaling WebSocket, and the small API surface served by the Go process.

func newRstreamClient(opts OpenOptions, useQUIC bool) (*rstream.Client, error) {
	resolution, err := rsconfig.ResolveFromEnv(rsconfig.ClientEnvOptions{
		RequireEngine: true,
		RequireToken:  true,
	})
	if err != nil {
		return nil, fmt.Errorf("resolve local rstream client configuration: %w", err)
	}
	if useQUIC {
		resolution.Resolved.Transport = &rstream.QUICTransport{}
	}
	return rsconfig.NewClientFromResolved(resolution.Resolved)
}

Once the tunnel is open, the public side still behaves like a regular listener. That keeps the HTTP serving code idiomatic and easy to reuse with the rest of the Go standard library.

func (m *Manager) Listener() net.Listener {
	return m.tunnel
}
 
server := &http.Server{Handler: handler}
serverErrors := serveHTTP(server, tunnelManager.Listener())

On the TURN side, the device asks rstream-go for credentials and passes them to Pion as ICE servers.

credentials, err := rsconfig.CreateTURNCredentialsFromEnv(ctx, p.options)
if err != nil {
	return nil, err
}
configuration := ICEConfig(credentials)

The helper that feeds Pion stays deliberately small.

func ICEConfig(credentials *rstream.TURNCredentials) webrtc.Configuration {
	return webrtc.Configuration{
		ICEServers: []webrtc.ICEServer{
			{
				URLs:       credentials.URLs,
				Username:   credentials.Username,
				Credential: credentials.Credential,
			},
		},
	}
}

The same project context drives both pieces. The device uses it to publish the viewer and to obtain TURN credentials for the WebRTC path. That is one of the main strengths of the setup. The first version does not need a separate backend dedicated to tunnel publication or TURN issuance. When the local context already contains the routing data for the project, the SDK can derive the TURN credentials directly from that context. Otherwise it falls back to the Control plane API. In both cases, the device keeps control of the TURN bootstrap path and the browser still talks directly to the device process during session setup.

More background on the managed TURN side is covered in STUN and TURN. The tunnel publication model is covered in HTTP Tunnels.

Run the reference path

config.h264.yaml is the reference profile. It uses a test pattern source, H.264, the default recovery path, and a fixed encoder bitrate. The shortest way to run it is the following command.

make run

The equivalent manual flow is shown below.

make build
./webrtc-video-streaming -config ./config.h264.yaml

With an active local rstream context, that is enough to bring the full stack online. The process starts locally and prints both the local URL and the public URL.

info  Local URL: http://127.0.0.1:8080
info  Public URL: https://xxxxxxxx.t.<cluster-domain>

Open the public URL in a browser. The page is served through the tunnel, the WebSocket signaling path stays on the same origin, the browser requests TURN credentials from the same process, and playback starts once the WebRTC session is established.

Standalone rstream WebRTC video streaming sample served through a published tunnel

The standalone sample serves the viewer and signaling endpoint from the device process, then exposes both through one rstream tunnel URL.

For a local-only run, use the following command.

make run-local

That disables publication and serves the viewer on http://127.0.0.1:8080.

Reference profiles

The repository ships several profiles so you can start from a known working configuration instead of assembling the whole file by hand.

ProfileSourceCodecAdaptive bitrateBest used for
config.h264.yamlvideotestsrcH.264OffReference path and first validation
config.av1.yamlvideotestsrcAV1OffCodec negotiation and AV1 transport testing
config.provisioning.h264.yamlvideotestsrcH.264OffProduct API provisioning in the next guide
config.macos-webcam.h264.yamlavfvideosrcH.264OffmacOS webcam with fixed bitrate
config.macos-webcam.h264.twcc-gcc.yamlavfvideosrcH.264OnmacOS webcam with adaptive bitrate
config.macos-webcam.av1.yamlavfvideosrcAV1OffmacOS AV1 evaluation
config.macos-webcam.av1.twcc-gcc.yamlavfvideosrcAV1OnmacOS AV1 plus adaptive bitrate
config.raspberry-pi-camera.h264.yamllibcamerasrcH.264OffRaspberry Pi camera with fixed bitrate
config.raspberry-pi-camera.h264.twcc-gcc.yamllibcamerasrcH.264OnRaspberry Pi camera with adaptive bitrate
config.raspberry-pi-camera.av1.yamllibcamerasrcAV1OffRaspberry Pi AV1 evaluation
config.raspberry-pi-camera.av1.twcc-gcc.yamllibcamerasrcAV1OnRaspberry Pi AV1 plus adaptive bitrate

H.264 is the default because it is the most predictable path for low-latency live capture across browsers and devices. AV1 is included because a modern device-side stack should be able to evaluate it, but it should be treated as a profile to benchmark on the target hardware, not as a universal replacement for H.264.

Tunnel authentication

The sample configures tunnel authentication with two explicit flags. If both are false, the tunnel is public. If token is true, the tunnel requires a valid rstream tunnel token. If rstream is true, rstream identity auth is enforced at the edge. The two flags are product policy, not WebRTC policy, and the decision is enforced on the tunnel before the request reaches the device process.

tunnel:
  auth:
    token: false
    rstream: false

The standalone sample does not build a public URL with an embedded device token. It logs the published URL returned by rstream and leaves viewer token distribution to a trusted surface. That is intentional. The device should not leak its own long-lived authentication material through a browser-visible URL.

For quick validation in a controlled environment, keep both flags false. For operator-facing workflows, rstream auth is usually the cleanest standalone option. For product-facing access where one backend issues short-lived producer and viewer tokens, use the provisioning mode covered in the next guide.

The broader authentication model is covered in Authentication and HTTP tunnel authentication.

Add feedback and packet repair

The sample enables the first useful layer of transport hardening by default. That matters on remote devices where the uplink may move between Wi-Fi, 4G, and 5G, where the radio environment may change during a session, or where the available bandwidth can collapse without warning.

TWCC gives the sender a transport-wide congestion signal. NACK lets the browser report packet loss. RTX gives the sender a retransmission path so those losses can actually be repaired. The combination is simple, practical, and broadly supported.

FlexFEC remains available as an opt-in, but the sample leaves it off by default because the overhead and browser support profile are less attractive than TWCC + NACK + RTX for this first deployment shape.

The viewer page exposes the signals that matter while you test.

  • negotiated codec
  • active recovery path
  • current auth mode
  • current TWCC target
  • current encoder target
  • runtime log for tunnel, signaling, ICE, and playback events

That is enough to validate the main behaviors without immediately opening chrome://webrtc-internals.

The important design detail is that this sample treats signaling and media as two different planes. The signaling plane is the HTTP/WebSocket surface served by the device process and published through the rstream tunnel. The media plane is the WebRTC path negotiated by ICE. Keeping those two planes separate is what makes the system resilient without turning the application code into a custom transport stack.

On the signaling side, the producer uses QUIC as its tunnel transport to the rstream engine.

tunnel:
  transport:
    useQuic: true

The public browser entrypoint remains a standard HTTP tunnel. The browser still loads the page and opens the signaling WebSocket normally. rstream can serve that public HTTP tunnel, including WebSocket traffic, over QUIC when the browser and network path negotiate it. The sample does not rely on that browser-side negotiation being selected. The explicit setting in the code is the producer-to-rstream tunnel transport, where QUIC helps the upstream tunnel survive changes such as an IP address or interface transition. That is especially useful for devices that may move between Wi-Fi, Ethernet, 4G, or 5G while a viewer is connected.

On the media side, WebRTC does not make one permanent routing decision and then hope for the best. ICE gathers local, server-reflexive, and relay candidates, forms candidate pairs, runs connectivity checks, and selects the pair that can carry the media. With Trickle ICE, candidates are sent as soon as they are discovered instead of waiting for complete gathering before the offer/answer exchange can continue.

The browser sends local candidates to the device over the signaling WebSocket.

peerConnection.onicecandidate = (event) => {
  if (!event.candidate || socket.readyState !== WebSocket.OPEN) {
    return;
  }
  socket.send(
    JSON.stringify({
      type: "webrtc.candidate",
      candidate: event.candidate.candidate,
      sdpMid: event.candidate.sdpMid,
      sdpMLineIndex: event.candidate.sdpMLineIndex,
    }),
  );
};

The device does the same in the opposite direction.

peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
	if candidate == nil {
		return
	}
	init := candidate.ToJSON()
	_ = send(SignalMessage{
		Type:          "webrtc.candidate",
		Candidate:     init.Candidate,
		SDPMid:        trimmedStringPtr(init.SDPMid),
		SDPMLineIndex: init.SDPMLineIndex,
	})
})

Because Trickle ICE is incremental, a candidate can reach the browser before the answer has been applied. The viewer buffers those remote candidates and flushes them once the remote description exists.

async function addRemoteCandidate(candidate: RTCIceCandidateInit) {
  if (!state.peerConnection) {
    return;
  }
  if (!state.peerConnection.remoteDescription) {
    state.pendingRemoteCandidates.push(candidate);
    return;
  }
  await state.peerConnection.addIceCandidate(candidate);
}
 
async function flushRemoteCandidates(sessionID: number) {
  while (state.pendingRemoteCandidates.length > 0) {
    const candidate = state.pendingRemoteCandidates.shift();
    if (!isCurrentSession(sessionID)) {
      return;
    }
    if (candidate && state.peerConnection) {
      await state.peerConnection.addIceCandidate(candidate);
    }
  }
}

After a session is connected, ICE keeps validating the selected path. If that path stops working, for example because the producer changes network interface, the browser can keep the same signaling session and send a new offer with ICE restart enabled. That forces fresh ICE credentials and fresh candidate gathering, so both peers can evaluate a new set of paths and move the media to a working candidate pair.

const offer = await peer.createOffer({ iceRestart: true });
await peer.setLocalDescription(offer);
socket.send(
  JSON.stringify({
    type: "webrtc.offer",
    sdp: offer.sdp,
  }),
);

The producer must not treat a transient disconnected state as an immediate teardown. The sample keeps the WebRTC session open during a recovery window and only closes it if ICE does not recover.

peerConnection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
	switch state {
	case webrtc.ICEConnectionStateConnected, webrtc.ICEConnectionStateCompleted:
		session.clearNetworkRecovery()
	case webrtc.ICEConnectionStateDisconnected, webrtc.ICEConnectionStateFailed:
		session.scheduleNetworkRecovery("ICE " + state.String())
	}
})

The result is a robust baseline for device streaming. QUIC helps keep the producer tunnel alive while the producer network changes, and the public tunnel can also use QUIC for browser traffic when the browser negotiates it. Trickle ICE keeps candidate exchange incremental. ICE restart gives the media path a way to recover when the selected candidate pair no longer works. TURN provides a relay path when direct connectivity is not possible.

This is also where adaptive bitrate becomes especially valuable. ICE and TURN keep the session connected; TWCC, GCC, NACK, RTX, and the encoder control loop keep the stream usable while the path quality changes. In practice, the combination of Trickle ICE for path discovery and adaptive bitrate for media pressure is the difference between a demo stream and a device stream that behaves well on real networks.

Adaptive bitrate

Once congestion feedback is in place, the next step is to let the encoder react to it. The sample includes a first adaptive bitrate backend named twcc-gcc. The transport estimate comes from the standard Pion TWCC and GCC path. The application layer then applies bounded bitrate updates to the active encoder in GStreamer.

That distinction is important. The sample does not try to replace the standard WebRTC estimator. It uses the standard estimator and adds a small policy layer that decides how quickly the encoder target is allowed to move inside a configured range.

The current backend only changes encoder bitrate. It does not rebuild the pipeline, change frame size, or change frame rate at the source. That makes the implementation small and useful, but it also constrains where it can be applied cleanly. One feedback loop should control one encoder, which means either

  • media.mode: per-viewer
  • or webrtc.maxViewers: 1

The relevant part of the configuration is shown below.

webrtc:
  maxViewers: 1
  initialBitrateKbps: 5000
  adaptive:
    enabled: true
    backend: twcc-gcc
    twccGCC:
      minBitrateKbps: 1500
      maxBitrateKbps: 8000
      updateInterval: 1s
      changeThresholdPct: 10
      maxIncreasePct: 15
      maxIncreaseStepKbps: 500

The sample starts at 5 Mbps, allows the encoder to move in the 1.5–8 Mbps range, and applies changes at a bounded cadence. That is a pragmatic first policy for device-side streaming. It is easy to reason about, easy to validate, and good enough to establish a working low-latency baseline.

For a production deployment, acting only on the encoder is often not the whole answer. Some devices behave better when the source itself is also adapted by reducing frame size, frame rate, or capture profile. The sample stops at encoder modulation because it keeps the implementation readable and directly reusable. In a fuller deployment, source adaptation is often the next step once the encoder-only loop is working and measured.

Simulate congestion

The adaptive path is only interesting if the network path is tested under pressure. Browser throttling is still useful for page behavior, but it is not the right tool to validate the device uplink. For that, shape the real interface that carries the media traffic.

On Linux, tc netem is the right baseline.

sudo tc qdisc add dev wlan0 root netem delay 80ms 20ms loss 3% rate 2mbit

To tighten the path further, use the following command.

sudo tc qdisc change dev wlan0 root netem delay 160ms 40ms loss 6% rate 1mbit

To remove the shaping, use the following command.

sudo tc qdisc del dev wlan0 root netem

The expected pattern is straightforward. TWCC target moves first as the transport estimate reacts to the new path. Encoder target follows within the configured update interval and within the configured rate-of-change bounds. If TWCC target is moving and the encoder target is flat, the application policy is the first thing to inspect. If both values move but the stream still behaves poorly, the encoder and source settings are the next place to inspect.

AV1

The repository includes AV1 profiles because a modern device-side streaming stack should be able to evaluate AV1, even if H.264 remains the reference path. AV1 matters for teams that want to explore more efficient compression, newer hardware, or future codec strategies without changing the rest of the transport architecture.

At the same time, AV1 on live capture is more dependent on the target machine and the selected encoder path than H.264. That is why the guide treats AV1 as an opt-in profile to benchmark on the actual device. The right operational question is not whether AV1 is available in theory. It is whether the chosen encoder, source, and hardware can sustain the latency and smoothness constraints of the deployment.

For the first version of this sample, H.264 remains the safe default. AV1 is there to evaluate, compare, and extend.

Package the code for Linux devices

Local development is straightforward.

make build
make test

The deployment path that matters for real devices is the Linux distribution build.

make dist-linux-amd64
make dist-linux-arm64
make dist

Those targets build a standalone Linux executable linked against a static gstreamer-full toolchain. The build compiles the GStreamer subset required by the sample, including the codec, parser, and appsink path used by the shipped profiles, and links the Go binary against that toolchain with musl.

The operational result is simple. Copy the binary and its config file to the target machine and run it there. That is the useful shape for Raspberry Pi deployments, embedded systems, and remote devices that do not have a full development stack installed.

The static GStreamer toolchain is defined in build-gstreamer-static-linux.sh. If the pipeline changes, the build script must change with it. Any new source element, encoder, parser, or plugin family introduced by the deployment has to be reflected in that script, otherwise the local development setup and the distribution build will drift apart.

What the next guide adds

This first guide keeps the whole flow inside the device process so the media path, tunnel policy, and TURN path can be validated with very little coordination. For some use cases, that is already enough.

The second guide, Build a Next.js WebRTC Video Platform with rstream, keeps the same device-side streaming code and adds a Next.js backend for device inventory, producer provisioning, TURN issuance, and viewer authorization. That is the production shape for teams that want rstream to sit behind their own product API rather than requiring every device to install the rstream CLI or hold long-lived rstream credentials.

References