Broadcast Topologies

One source. Many viewers. No server in the media path.

That is the constraint this article works inside. WebRTC moves media peer-to-peer over RTCPeerConnection. STUN helps two peers find each other, but nothing relays their frames. This site ships no SFU, no media gateway, no CDN, and never will. That is a hard project rule. So broadcast here means a topology built entirely out of direct peer connections.

That constraint is honest, and it has consequences. Pure peer-to-peer broadcast works for a handful of viewers. Past that, the source runs out of uplink, or the topology grows fragile, or latency stacks up. This article shows exactly where each wall sits, with the arithmetic, so you can pick a topology on purpose instead of discovering the ceiling in production.

Read Capture first if you need a source stream. Read Encoding to understand the bitrate numbers used below: they decide everything here.

The problem

A single peer holds a MediaStream: a camera, a screen share, a canvas, a game render. Several other peers want to watch it live. You have signaling (the site offers manual paste, URL-hash, and ntfy.sh modes; all carry SDP only). You have STUN. You have RTCDataChannel and you have media tracks. You have no server that touches a single frame.

The question is purely about shape: who connects to whom, and who sends the stream to whom.

Three shapes are possible without a media server:

  • Star fanout. The source opens one peer connection per viewer and sends the stream once down each. Every viewer talks only to the source.
  • Relay tree. The source sends to a few viewers; those viewers re-forward the stream to viewers below them. Uplink spreads across the tree.
  • Hybrid. A star at the top, relay branches below, or a star that promotes a viewer to relay when uplink runs low.

A full mesh (every peer connected to every other peer) is not a broadcast topology. It is a group call. In a mesh, everyone is a source. Here, exactly one peer is the source and the rest only receive. That asymmetry is the whole point, and it is what makes the source's uplink the binding constraint.

STAR RELAY TREE MESH depth 2, uplink spreads not broadcast, group call

Before the topologies, fix the number that governs all of them.

Upload bandwidth is the budget

Download is cheap and asymmetric connections are normal. A home line might pull 200 Mbps down but push only 10 to 20 Mbps up. Mobile uplink is worse and varies by the second. The source's upload capacity is the budget every topology spends.

A video track has a target bitrate. Call it B. If the source sends that track to N peers over N separate connections, the source uploads N × B. WebRTC does not deduplicate identical media across connections: each RTCPeerConnection carries its own encrypted SRTP stream over its own path. Two viewers on the same stream cost twice the uplink. There is no broadcast primitive at the transport layer.

So the first design lever is B itself, set by encoding. Lower resolution, lower frame rate, and a lower target bitrate buy more viewers per megabit. The second lever is the topology: how the N × B total gets distributed across machines.

Star fanout

The source is the hub. Each viewer is a spoke. One RTCPeerConnection per viewer, each carrying the full stream.

This is the model the repo already implements for games. peer-pool.js holds N peer connections on the host, one per guest, indexed by slotIdx. It was built for data channels (worms, three-player games), but the shape is identical for media: replace createDataChannel with addTrack, and the host fans one stream out to every slot.

slot 0 slot 1 slot 2 slot 3 slot 4 source (host) N × B up

How it works

The source creates one connection per viewer, adds the stream's tracks, and runs the normal offer/answer handshake on each connection independently. Viewers never connect to each other and do not know how many other viewers exist.

// Source side: fan one MediaStream out to N viewers over N connections.
// Mirrors peer-pool.js (one RTCPeerConnection per slot), but with tracks
// instead of a data channel.

import { ICE } from '../salon/ice.js';
import { waitIce } from '../salon/peer.js';

function broadcastPool(stream) {
  const conns = []; // one entry per viewer

  async function inviteViewer() {
    const pc = new RTCPeerConnection({ iceServers: ICE });

    // Add every track of the source stream to THIS connection.
    // Each connection gets its own encoder output and its own uplink cost.
    for (const track of stream.getTracks()) {
      pc.addTrack(track, stream);
    }

    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    await waitIce(pc, 4500); // partial candidates are usually enough

    const slotIdx = conns.push(pc) - 1;
    return { slotIdx, code: JSON.stringify(pc.localDescription) };
  }

  async function acceptAnswer(slotIdx, answerCode) {
    await conns[slotIdx].setRemoteDescription(JSON.parse(answerCode));
  }

  function viewerCount() {
    return conns.filter(pc => pc.connectionState === 'connected').length;
  }

  function close() {
    for (const pc of conns) { try { pc.close(); } catch {} }
    conns.length = 0;
  }

  return { inviteViewer, acceptAnswer, viewerCount, close };
}

The viewer side is a plain receiver. It accepts the offer, answers, and renders the incoming track:

// Viewer side: receive one track and show it.
async function joinBroadcast(offerCode) {
  const pc = new RTCPeerConnection({ iceServers: ICE });

  pc.ontrack = (event) => {
    document.querySelector('video').srcObject = event.streams[0];
  };

  await pc.setRemoteDescription(JSON.parse(offerCode));
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  await waitIce(pc, 4500);
  return JSON.stringify(pc.localDescription); // hand back to the source
}

That is the entire mechanism. No relay logic, no tree maintenance. Adding a viewer is one more handshake; removing one is one pc.close(). The source's connectionState per slot tells you who is live.

The signaling path is the only coordination needed, and the site already has three modes for it. Manual paste works for a tiny invited group. URL-hash scales the invite to anyone who opens a link. ntfy.sh pub/sub lets viewers join without the source manually pasting each answer back: the source publishes its offer to a topic, viewers subscribe, answer, and the source picks up the answers from the same topic. None of these touch a frame; they carry SDP and nothing else. The choice of signaling mode is independent of the broadcast topology: any mode can drive any shape, because all of them only move the offer/answer codes around.

Watching the source's budget with getStats

The source can measure its own outbound bitrate per connection and refuse the next viewer before quality collapses. getStats() exposes the actual bytes sent per outbound RTP stream; compare the sum against a configured uplink budget.

// Sum outbound video bitrate across all connections, in bits per second.
// Call periodically; if it nears your uplink budget, stop accepting viewers.
async function totalOutboundBitrate(conns, prev) {
  let totalBytes = 0;
  let now = 0;
  for (const pc of conns) {
    const stats = await pc.getStats();
    stats.forEach((report) => {
      if (report.type === 'outbound-rtp' && report.kind === 'video') {
        totalBytes += report.bytesSent;
        now = report.timestamp;
      }
    });
  }
  const dt = (now - prev.timestamp) / 1000; // seconds since last sample
  const bps = dt > 0 ? ((totalBytes - prev.bytes) * 8) / dt : 0;
  return { bps, sample: { bytes: totalBytes, timestamp: now } };
}

Admission control turns the silent quality-collapse failure into an explicit refusal: the source tells the would-be viewer "full" instead of degrading the existing audience. That single check is the difference between a star that fails gracefully and one that falls apart under load.

The uplink ceiling

Star fanout puts the whole cost on one machine. Do the arithmetic.

Take a modest 720p stream at B = 2.5 Mbps, a reasonable encoding target for talking-head or screen content.

  • 5 viewers: 5 × 2.5 = 12.5 Mbps up. Fine on most fixed broadband.
  • 20 viewers: 20 × 2.5 = 50 Mbps up. Beyond a typical home uplink; you need symmetric fibre or a datacentre line.
  • 100 viewers: 100 × 2.5 = 250 Mbps up. Datacentre-class, and at that point you would not be running it from a browser tab.

Drop to a 360p stream at B = 0.6 Mbps and the numbers ease but the shape does not change:

  • 5 viewers: 3 Mbps up.
  • 20 viewers: 12 Mbps up.
  • 100 viewers: 60 Mbps up, still past most uplinks.

The ceiling is linear and unforgiving. Every viewer adds B. There is no economy of scale because there is no shared transmission. The source either has the uplink or it does not.

Worse, the failure mode is not a clean cutoff. When the source's uplink saturates, packets queue and drop. WebRTC's congestion control (the receiver-estimated bitrate it negotiates per connection) reacts by lowering the bitrate, but it reacts per connection, and the connections compete for the same starved pipe. The result is that every viewer's quality degrades at once, often unevenly, with stutter and resolution drops. One slow viewer's packet loss can also drag down the encoder if tracks are shared, because a single encoder feeding multiple addTrack calls adapts to the worst path. Adding the eleventh viewer can visibly hurt the first ten.

Simulcast does not save you here

Simulcast lets one source send multiple resolution layers so a server can forward the right layer to each viewer. Without a server to pick layers per receiver, simulcast on a direct connection just means the source uploads more data, not less. It is an SFU feature. In pure P2P star fanout it adds uplink cost, not relief.

Where star fanout fits

Small, fixed audiences where the source has known uplink. A watch-party of five. A screen share to a handful of reviewers. A live demo to a classroom on a wired connection. The code is trivial, latency is minimal (one hop, source to viewer), and any single viewer leaving affects no one else.

The star's strength is that it is flat. There is exactly one hop. No viewer depends on another viewer. That makes it the most reliable topology, right up until the uplink runs out.

One more practical point: the star is the only topology that maps directly onto code the repo already ships and tests. peer-pool.js is in production for games, indexed by slotIdx, with broadcast, send, and per-slot connectionState already wired. Swapping its data channel for media tracks is a small, well-understood change. Every other topology below is new code with no equivalent in the codebase, which is itself a reason to reach for the star first and only grow past it when the viewer count forces the issue.

Relay trees

If the source cannot afford N × B, push some of the sending onto the viewers. A viewer that receives the stream re-forwards it to viewers below it. The source feeds a few top-level viewers; each of those feeds a few more; the tree grows downward.

This is sometimes called application-layer multicast. The source's uplink now only pays for its direct children, not the whole audience.

source 2 × B up relay relay 2 × B up 2 × B up (forward) (forward) 0 up 0 up 0 up 0 up depth 2, fan-out 2 → 6 viewers, source uploads only 2 × B

The bandwidth math changes

With fan-out k (each node forwards to k children), the source uploads only k × B regardless of total audience. The cost spreads across every interior node. A balanced tree of depth d and fan-out k reaches k + k² + … + kᵈ viewers while the source pays just k × B.

Concretely, fan-out 2:

  • Depth 2: source feeds 2, they feed 4 → 6 viewers, source uploads 2 × B.
  • Depth 3: → 14 viewers, source still uploads 2 × B.
  • Depth 4: → 30 viewers, source still uploads 2 × B.

The source's uplink is no longer the ceiling. Every non-leaf viewer now spends k × B of its own uplink to forward, which is the catch: a home viewer forwarding to two others at 2.5 Mbps each needs 5 Mbps up, and many do not have it. Forwarding capacity becomes the new scarce resource, distributed across machines you do not control.

Latency stacks per hop

The star has one hop. A tree has as many hops as its depth. Each hop adds the full receive-decode-re-encode-send delay of the relaying browser, plus that link's network latency.

A relay node cannot forward frames it has not received. In practice a browser relay re-transmits the incoming track on an outgoing connection, which means the frame is received, possibly jitter-buffered, and sent again. Each level adds tens to low hundreds of milliseconds. A depth-4 tree can stack half a second or more of glass-to-glass latency onto the deepest viewers. For a watch-party that may be fine. For anything interactive, it is not.

Latency is also uneven. A viewer two hops down sees the stream noticeably later than a viewer one hop down. The audience is no longer synchronized: viewers on different branches see different moments, which matters for live reactions, sports, or anything where spoilers travel through a side channel like chat.

Fragility: the middle-node problem

The star's reliability came from being flat. The tree throws that away. Every interior node is a single point of failure for its entire subtree.

When a relay node closes its tab, loses its connection, or simply runs out of uplink, every viewer below it goes dark at once. Recovery means detecting the failure, finding the orphaned viewers a new parent, and renegotiating WebRTC connections, all over your signaling channel, while the stream is interrupted.

That repair logic is the hard part of relay trees, and it is substantial:

  • Failure detection. Watch connectionState and iceConnectionState on every parent-child link. A disconnected state may recover; failed will not. You need timeouts tuned to avoid both false positives (re-parenting on a transient blip) and slow reaction (frozen video for seconds).
  • Re-parenting. Pick a new parent for each orphan with spare forwarding capacity, then run a fresh offer/answer handshake. The orphan's own children may need re-parenting too if it was itself a relay.
  • Tree construction. Deciding who relays to whom, balancing depth (latency) against fan-out (per-node uplink), and avoiding cycles. This is bookkeeping the source or a coordinator must maintain over signaling.
  • Trust. Every relay sees and re-transmits the full stream. In the star, only the source has the media. In a tree, every interior viewer is now in the media path. For private content that is a real change in the threat model. See security in the connecting deep-dive.

None of this rides a media server, so it is all legal under the project's constraints. But it is a lot of distributed-systems code to maintain a topology that is inherently less stable than the star, built on top of consumer connections you cannot predict.

To make the cost concrete, here is the minimum a relay node needs to do. It is both a viewer and a source: it receives one inbound track and re-adds it to its own outbound connections.

// A relay viewer: receive the stream, then forward it to children.
// The inbound MediaStreamTrack from ontrack is added to each child
// connection: the relay re-transmits what it receives.
function makeRelay() {
  let inbound = null;           // the track received from the parent
  const children = [];          // connections to viewers below this node

  const parentPc = new RTCPeerConnection({ iceServers: ICE });
  parentPc.ontrack = (event) => {
    inbound = event.streams[0];
    // Back-fill any children that joined before the track arrived.
    for (const child of children) attach(child, inbound);
  };

  function attach(pc, stream) {
    for (const track of stream.getTracks()) pc.addTrack(track, stream);
  }

  async function inviteChild() {
    const pc = new RTCPeerConnection({ iceServers: ICE });
    children.push(pc);
    if (inbound) attach(pc, inbound); // forward only once the stream exists
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    await waitIce(pc, 4500);
    return JSON.stringify(pc.localDescription);
  }

  return { parentPc, inviteChild, children };
}

What this sketch leaves out is exactly what makes trees hard in practice: who decides this node should relay, who its parent is, how many children it may take, and what happens to children when parentPc goes to failed. That coordination logic (the tree manager) is larger than all the WebRTC plumbing combined, and it has no counterpart in the repo. A coordinator (often the source) must track the whole tree over signaling, assign parents, enforce per-node fan-out caps, and run re-parenting on failure. That is the real cost of the tree: not the forwarding, but the bookkeeping that keeps it standing.

Where relay trees fit

Audiences too big for the source's uplink but small enough that you control or trust the relays, where some extra latency is acceptable, and where you are willing to write and maintain the re-parenting logic. A trusted group of friends watching together, each with decent uplink, is the realistic case. An open public audience of strangers on flaky mobile links is the case where the tree spends all its time repairing itself.

Hybrid approaches

The two pure shapes trade off against each other. Hybrids try to keep the star's reliability where uplink allows and borrow the tree's spreading only where needed.

Star with promotion. Run a star while the source's uplink holds. When a new viewer would push the source past its budget, promote an existing well-connected viewer to relay and attach the newcomer below it. The topology stays a flat star for most viewers and grows shallow branches only at the margin. This keeps depth low (so latency stays bounded) and only puts forwarding load on viewers that have measured spare uplink.

Capped fan-out per node. Set a maximum children per node (including the source) and let the tree grow only as wide as each node's uplink allows. A node with 10 Mbps up and B = 2.5 forwards to at most three children. Measure available uplink with getStats() (outbound-rtp bitrate versus target) and refuse to accept children that would exceed it.

Quality tiers by branch. The source can encode different branches at different bitrates by giving each connection its own encoder target. Viewers on fast paths near the top get the full stream; deeper or slower branches get a lower-bitrate track. Without an SFU you set this manually per connection rather than having a server pick layers, but the lever exists. You set the per-connection target through the sender's encoding parameters:

// Cap the bitrate a particular outbound connection uses.
// Lets the source feed a slow branch a smaller stream than a fast one.
function capSenderBitrate(pc, maxBps) {
  for (const sender of pc.getSenders()) {
    if (sender.track?.kind !== 'video') continue;
    const params = sender.getParameters();
    params.encodings ??= [{}];
    params.encodings[0].maxBitrate = maxBps;
    sender.setParameters(params);
  }
}

The promotion decision itself is just admission control plus a fallback. When a new viewer arrives, the source checks its remaining budget. If there is room, it serves the viewer directly (star). If not, it finds an existing viewer with measured spare uplink and hands the newcomer off to relay below it (branch). The whole policy fits in a handful of lines:

// Decide where a new viewer attaches: directly to the source, or below
// an existing viewer that has spare forwarding capacity.
function placeViewer({ sourceSpareBps, viewers, streamBps }) {
  if (sourceSpareBps >= streamBps) {
    return { parent: 'source' }; // keep the star flat
  }
  // Source is full: find the shallowest viewer that can afford a child.
  const relay = viewers
    .filter(v => v.spareBps >= streamBps)
    .sort((a, b) => a.depth - b.depth)[0]; // shallowest → lowest added latency
  if (relay) return { parent: relay.id };
  return { parent: null }; // nobody can take it: refuse, do not degrade
}

Sorting candidate relays by depth keeps branches shallow, which keeps the worst-case latency bounded, the opposite of letting a tree grow tall wherever it happens to find capacity. Returning null rather than overloading someone preserves the graceful-refusal property of admission control: a viewer that cannot be served well is told so, instead of quietly ruining the stream for the rest.

Hybrids do not remove the fundamental limit. They push it back by a constant factor and add complexity. A star-with-promotion design might comfortably serve 30 to 50 viewers where a pure star died at 15, but it is now carrying both the fanout code and the tree-repair code, and it is still bounded by the total uplink available across cooperating peers.

Comparing the topologies

Topology Source uplink Added latency Fragility Viewer ceiling (pure P2P)
Star fanout N × B: scales with every viewer One hop (minimal) Low: viewers independent, one leave affects no one ~5-15, capped by source uplink
Relay tree k × B: fixed at fan-out, regardless of N One hop per level: stacks with depth High: every interior node is a single point of failure for its subtree ~20-50, capped by aggregate peer uplink + repair cost
Hybrid Between the two: flat until budget, then branches Bounded if depth is capped Medium: star core stable, branches need repair ~30-50, plus the most code to maintain
Mesh (group call) (N−1) × B per peer: everyone is a source One hop Medium ~4-6, not a broadcast model
SFU / CDN (not in this project) 1 × B: source uploads once One hop + server Low: server absorbs failures Thousands to millions

The pattern in the table is the whole story. Pure P2P broadcast has no row where the source uploads once and the audience is large. That row requires a server in the media path, and this project does not have one.

Going further: where a server takes over

Be honest about the wall. Every pure-P2P topology above is bounded by the uplink of consumer machines. The star caps at the source's uplink. The tree spreads that load but trades it for latency, fragility, and a pile of repair code, and it is still capped by the aggregate uplink of cooperating peers. Neither reaches a large public audience, and no amount of cleverness changes that: the arithmetic is fixed by physics and home internet plans.

Past a small audience, the answer is a media server, which is exactly what this project leaves out by design.

  • SFU (Selective Forwarding Unit). The source uploads its stream once to the server. The server forwards it to every viewer. Source uplink becomes 1 × B regardless of audience size. The SFU also does the per-viewer work that P2P cannot: picking the right simulcast layer for each connection, absorbing one viewer's packet loss without poisoning the encoder, and surviving viewer churn without re-parenting anyone. mediasoup, Janus, LiveKit, and Jitsi are SFUs. This is the standard answer for tens to low thousands of live viewers at WebRTC latency.
  • CDN with HLS/DASH or low-latency variants. For audiences in the thousands to millions, the live stream is segmented and served from edge caches. Latency rises from sub-second to a few seconds (less with LL-HLS or WebRTC-to-CDN ingest paths), but reach becomes effectively unbounded. This is how large live events are delivered.

Both put a server in the media path. That is the line this project draws and does not cross: gameplay and media here stay peer-to-peer, signaling carries SDP only, and STUN is the only infrastructure. Knowing where the wall is means you can ship the P2P version honestly and reach for an SFU only when the audience actually demands it.

What pure P2P broadcast is genuinely good for

The constraint is not only a limit. A serverless broadcast has real properties a server cannot offer:

  • No infrastructure. No SFU to deploy, scale, monitor, or pay for. The audience's machines are the infrastructure.
  • Privacy. In a star, the media touches only the source and each viewer. No server holds or sees the stream. (A relay tree weakens this: interior viewers see the stream, so keep that in mind for private content.)
  • Ephemerality. Nothing is recorded or persisted anywhere by default. The broadcast exists only while peers are connected.
  • Low latency. A one-hop star beats any server round-trip. For a tiny interactive audience, P2P is the lowest-latency option, not a compromise.

Tiny audiences, watch-parties, live demos, classroom shares, ephemeral one-off broadcasts among people who trust each other: these are where pure P2P broadcast is the right tool, not a fallback. Use the star, keep B modest, and know the viewer count you are designing for.

Troubleshooting

Adding viewers degrades quality for everyone. Classic uplink saturation in a star. Sum N × B against the source's measured upload. Lower B via encoding, or switch to a hybrid that offloads forwarding. Confirm with getStats(): a falling outbound-rtp bitrate and rising packet loss across all connections at once is the signature.

One viewer's bad connection drags down the others. A single encoder feeding multiple addTrack calls adapts to the worst receiver. Give each connection its own sender so congestion control on a slow path does not lower the bitrate for fast viewers. In a star this means not sharing one encoder across all slots when paths differ widely.

Deep tree viewers see noticeable lag. Latency stacks per hop. Reduce tree depth: widen fan-out where uplink allows, or cap depth and refuse deeper attachments. Measure per-branch delay; do not assume the tree is balanced.

A relay leaves and a whole branch freezes. The middle-node problem. You need failure detection on every parent-child link (connectionState going to failed) and re-parenting logic. If you have not written that, the tree cannot recover. This is the cost the star avoids entirely.

Viewers connect but never receive video. Check that ontrack fires and event.streams[0] is attached to the <video> element. Confirm the source actually called addTrack on that connection before creating the offer: tracks added after setLocalDescription need a renegotiation. Verify the handshake completed: a connection can reach connected for data while a track is still negotiating.

ICE fails for some viewers, not others. STUN-only means symmetric NATs on both ends can fail to connect with no relay fallback. This is inherent to the project's no-TURN rule. See ICE and NAT. In a relay tree, an unreachable parent-child pair simply cannot form; pick a different parent for that viewer.


Pick the topology before you write the code. A star is a few lines and serves a small fixed audience reliably. A tree spreads uplink at the cost of latency, fragility, and real repair logic. Neither reaches a large public audience: that is what an SFU or CDN is for, and this project leaves them out on purpose. Match the shape to the size of the room, and lower B before you reach for a more complex topology.