Gryt

Overview

The Go SFU that carries voice, video and screen share

The SFU is the media plane. Every participant sends their audio, camera and screen share to it once, and it forwards each of those streams on to everybody else in the room. Nothing is transcoded on the way through — the packets that arrive are the packets that leave.

It is a Go service built on Pion WebRTC v4, and it is deliberately small: one of it runs per machine, shared by every Gryt server on that machine.

How it handles media

Forwarding is the whole job. A room of five people means each client sends one copy up and receives four down, so the SFU spends almost no CPU compared to something that mixes streams together, and upload bandwidth is what runs out first.

Streams that carry the Dependency Descriptor RTP header extension get parsed for their temporal layer, which means the forwarder is able to drop the higher layers for a receiver that cannot keep up. It does not do that on its own. Every receiver gets every layer unless a client asks for fewer with set_layer, so the frame rate you configured is the frame rate you get, instead of sagging whenever a bandwidth estimate dips.

Receiver feedback is relayed selectively. A receiver reporting a lost picture (PLI) or asking for a full intra refresh (FIR) results in the SFU asking the original sender for a keyframe. Bandwidth estimates (REMB) go no further than the SFU and play no part in choosing layers. The sender's own congestion controller already has better information from transport-cc, which measures the sender's link to the SFU instead of somebody else's link away from it. The reasoning is written down in internal/signaling/coordinator.go if you want the longer version.

The SFU never sees message content. Text, identity and permissions belong to the Gryt server. Room IDs, peer IDs and RTP are all it knows about.

Codecs

Registered at startup, in registerCodecs. The client picks with setCodecPreferences; the SFU forwards whatever gets negotiated.

KindCodecs
AudioOpus (48 kHz stereo, in-band FEC), G.722, PCMU, PCMA
VideoH.264 (five profile/packetization combinations), VP9 (two profiles), VP8, AV1

H.264 leads the video list because every current GPU encodes it in hardware — NVENC, Quick Sync, AMF. AV1 is there for hardware new enough to want it.

Configuration

Every variable the SFU reads. There are no others.

VariableDefaultWhat it does
SFU_PORT5005HTTP and WebSocket port. PORT is read as a fallback
ICE_UDP_MUX_PORT3478The one UDP port all media flows over
STUN_SERVERSstun:stun.l.google.com:19302Comma-separated, used both for ICE and for the startup readiness probe
DISABLE_STUNfalseStop discovering server-reflexive candidates
ICE_ADVERTISE_IPComma-separated IPs to advertise as host candidates instead of the ones found on the interfaces
MAX_PEERS200How many peers may be connected at once, across all rooms
DEBUGtrueRoom, connection and signaling logging. On unless explicitly set to false
VERBOSE_LOGfalsePer-packet RTP forwarding detail

One UDP port, not a range

All media shares ICE_UDP_MUX_PORT. One port is far easier to get through a firewall than a range, and restrictive networks that drop UDP on high ports often let a port they recognise through.

The default is 3478, the IANA STUN port. It needs no privileged bind, and it is the UDP port a locked-down network has most likely already opened, since Microsoft Teams requires outbound 3478–3481.

UDP 443 looks like the better choice and generally is not. Enterprise firewall vendors recommend blocking it, because QUIC on UDP 443 cannot be TLS-inspected and blocking it forces browsers back to TCP where it can be. On the networks you would pick 443 to get through, it is the one most likely to be shut on purpose.

Ports are per-protocol either way. UDP 443 and TCP 443 are different sockets, so an HTTPS server never collides with media on UDP 443. HTTP/3 does, because that is UDP 443 as well, and Caddy serves it by default.

The SFU binds this port at startup and refuses to start if something else holds it. That is deliberate: the alternative is coming up healthy and handing out candidates on ports nobody opened.

The port range is gone

ICE_UDP_PORT_MIN and ICE_UDP_PORT_MAX were removed. If they are still set anywhere, they are ignored. A range never bought capacity — one muxed port carries far more peers than a machine has CPU and upload bandwidth for. If you genuinely outgrow one SFU, run a second one.

MAX_PEERS

A guardrail on the machine rather than a limit the port imposes. When the count of peers across every room reaches it, the next client to join is refused with "there are no seats left in this voice server".

This is separate from the Gryt server's own VOICE_MAX_USERS, which caps a single server's voice channels. The SFU's cap covers every server sharing it.

Endpoints

EndpointWhat it does
GET /health{"status":"healthy","service":"sfu","version":…,"timestamp":…}
GET /metricsPrometheus
GET /WebSocket upgrade. Anything else gets 400 and a sentence saying so
WS /serverThe Gryt server's control connection
WS /clientA participant's connection

Health has a starting state

Before the SFU reports healthy it sends a STUN Binding Request and waits for a reply, retrying up to eight times with a backoff that grows to five seconds. Until that succeeds, /health answers 503 with {"status":"starting", "detail":"verifying UDP connectivity"}.

This exists because Docker Desktop on Windows and macOS can finish starting a container before its UDP forwarding rules are in place, which used to break ICE on the very first docker compose up and look like a configuration problem. With DISABLE_STUN=true or no STUN servers, the probe is skipped and the SFU is ready immediately. If all eight attempts fail it gives up and reports healthy anyway, with a warning in the log.

Metrics

Four gauges, resynced from real state every fifteen seconds: gryt_sfu_rooms_active, gryt_sfu_peers_active, gryt_sfu_websocket_connections_active, gryt_sfu_tracks_active. Standard Go runtime metrics come along with them.

The WebSocket protocol

Raw WebSocket, not Socket.IO. Every message is { "event": "<name>", "data": "<json string>" } — note that data is a string containing JSON, not a nested object.

There are two kinds of connection, and they are told apart by path. /server is the Gryt server's control channel. /client (and anything else) is a participant.

The server's connection

The Gryt server connects once and registers each voice room. Every message after registration carries server_id and server_password, and is dropped without a reply if they do not match.

EventDirectionPayload
server_registerserver → SFU{ server_id, server_password, room_id }
disconnect_userserver → SFU{ room_id, user_id, server_id, server_password }
user_audio_controlserver → SFU{ room_id, user_id, server_id, server_password, is_muted, is_deafened }
sync_requestserver → SFU{ server_id, server_password }
sync_responseSFU → server{ rooms: [{ room_id, user_ids }] }
peer_joined / peer_leftSFU → server{ room_id, user_id }
keep_aliveeither{ timestamp }

sync_request is how the server reconciles after a restart: it asks who is actually connected rather than trusting its own record.

A participant's connection

The first message has to be client_join. Anything else and the connection is closed with "Expected client_join event".

EventDirectionPayload
client_joinclient → SFU{ room_id, server_id, server_password, user_token, user_id }
room_joinedSFU → client
room_errorSFU → clientThe reason, as a string
offerSFU → clientSDP
answerclient → SFUSDP
candidatebothRTCIceCandidateInit JSON
renegotiateclient → SFU
set_layerclient → SFU{ track_id, max_temporal_layer }
keep_aliveclient → SFU{ timestamp }

max_temporal_layer is -1 for every layer, or 0, 1, 2 for T0, T0+T1 and T0+T1+T2. It only does anything on a stream that actually carries the Dependency Descriptor extension.

A call, start to finish

  1. The Gryt server registers the room over /server.
  2. A client connects to /client and sends client_join. The SFU checks the room exists and the server credentials match, then checks MAX_PEERS.
  3. A peer connection is created with receive-only transceivers for audio, video, screen-share video and screen-share audio.
  4. The SFU offers, the client answers, candidates go both ways.
  5. Each track the client publishes gets a LayerForwarder: one goroutine reading RTP, parsing the Dependency Descriptor if present, and writing to one local track per receiver.
  6. Each forwarder sends the sender a PLI every two seconds, and relays any PLI or FIR from a receiver as a PLI to the sender. FIR is translated to PLI rather than passed through.
  7. On leave, tracks, forwarders and the peer connection are torn down, and the server is told with peer_left.

Rooms that have been empty for thirty minutes are removed by a sweep that runs every five minutes.

Layout

sfu/
├── cmd/sfu/            # main: config, codecs, the mux, HTTP, cleanup loops
└── internal/
    ├── config/         # every environment variable, in one place
    ├── readiness/      # the STUN probe /health gates on
    ├── recovery/       # panic containment around every goroutine and handler
    ├── room/           # rooms, registration, peer bookkeeping, cleanup
    ├── signaling/      # offer/answer coordination, RTCP relay
    ├── svc/            # Dependency Descriptor parser and LayerForwarder
    ├── track/          # track lifecycle
    ├── webrtc/         # peer connection management
    ├── websocket/      # the two connection kinds and their handlers
    └── metrics/        # the four gauges

Running it

cd packages/sfu
cp env.example .env
go run ./cmd/sfu
go test ./...
go run -race ./cmd/sfu

Most people never do this. gryt runs one SFU per machine and shares it between every server there — see the CLI docs.

On this page