How MoQT Relays Work: Aggregation, Caching, and Fan-Out
By moqtap team
How MoQT Relays Work
The first two posts in this series treated the publisher and subscriber as if they talked directly. In almost every real deployment, they don’t. Sitting between them is one or more relays, and the relay is where MoQT earns its “scales like a CDN, feels like a socket” reputation.
A relay is defined with deceptive simplicity in draft-ietf-moq-transport-18: an entity that is both a Publisher and a Subscriber, is not the Original Publisher or End Subscriber. It subscribes upstream like any client, and it publishes downstream like any source. That’s the whole trick — a relay is invisible to the application on both sides, which is exactly what lets you chain them into a delivery tree.
This post is about what a relay does with that dual role: aggregate, cache, schedule, propagate, authorize, and drain. It builds on how subscriptions work and delivery order and priority.
The core job: one upstream subscription, many downstream
The single most important thing a relay does is subscription aggregation. When a thousand subscribers all want the same live track, the relay does not open a thousand subscriptions to the original publisher. It opens one, and fans the resulting objects out to all thousand.
Original Publisher │ │ 1 upstream SUBSCRIBE ▼ ┌─────────┐ │ Relay │ ← caches objects as they pass └─────────┘ ╱ │ ╲ ▼ ▼ ▼ N downstream subscribers, sub sub sub … each with its own SUBSCRIBE, filter, and priorityThis is what makes MoQT fan-out cheap. The original publisher’s upload cost is flat no matter how many viewers show up — it serves one subscription and the relay tree multiplies it. It’s the same economics as an HLS/DASH CDN, except the unit being multiplied is a live object stream, not a cached segment file, so the added latency per hop is a forwarding decision rather than a segment duration.
Aggregation is also why the subscription model has the shape it does. Each downstream subscriber sends its own SUBSCRIBE with its own filter — one joins at the live edge with Largest Object, another at the next clean group with Next Group Start, a third pulls an AbsoluteRange from the past. The relay reconciles all of them against the single stream of objects it’s pulling upstream, plus whatever it has cached.
Caching: objects don’t change, so they can be reused
A relay caches objects as they flow through, and MoQT’s data model is built to make that safe. The governing rule: an object can become unavailable, but its contents MUST NOT change over time. Objects are immutable and content-addressable by their {track, group, object} location. Once a relay has group 42 / object 7, that is group 42 / object 7, forever — it can hand the cached copy to a late joiner without re-fetching upstream.
Two hard constraints keep relay caches honest:
- A relay MUST NOT combine, split, or otherwise modify object payloads. It forwards bytes, it doesn’t transcode them. A relay is a router, not an encoder.
- Properties a relay doesn’t understand MUST be forwarded and cached unmodified. A relay that doesn’t recognize some track property can’t drop it or rewrite it — it passes it through intact so an endpoint that does understand it still works. (Draft-18’s mandatory-to-understand track properties tighten this further; see the draft-18 changes post.)
How long may a relay hold an object? That’s bounded by the track’s max cache duration, after which the relay is free to evict. This is the mechanism behind a subtlety from the subscriptions post: an AbsoluteRange subscription reaching into the past only succeeds for objects still inside the cache window. Ask for something older and the range was valid but the data is gone.
Caching is also what makes FETCH work. A FETCH is a request/response over historical objects, and a relay serves it straight from cache when the requested objects are present and unexpired — no upstream round-trip. Subscribe-for-the-future and fetch-for-the-past both bottom out in the relay’s object cache.
Priority: forwarded, but re-scheduled at every hop
Here’s the part that surprises people. A relay does two apparently contradictory things with priority, and both are correct:
- Publisher Priority travels untouched. The priority the original publisher stamped on each subgroup and object is forwarded end-to-end, unmodified. The relay is not entitled to overrule the content author’s opinion of what matters within a track.
- The relay runs the scheduler locally, per downstream subscriber. When the relay has data ready for many subscribers on a congested downstream link, it applies the scheduling hierarchy independently for each one, using that subscriber’s Subscriber Priority.
The consequence: two subscribers pulling the same track through the same relay can receive its subgroups in different orders, because each set its own subscriber priority and each has its own downstream congestion. The objects are identical and their publisher priorities are identical; only the local send order differs. Once you internalize that priority is applied per-hop-per-subscriber while the object metadata rides through unchanged, a lot of “why did the relay reorder my stream?” confusion evaporates.
Propagation: how a relay knows what exists
A relay can’t forward a track it’s never heard of, so MoQT gives it a discovery plane built from namespace messages.
- Publishers advertise with
PUBLISH_NAMESPACE. A relay that has received an authorized announcement for a namespace becomes authoritative-by-proxy for it: when a downstream subscriber sendsSUBSCRIBE_NAMESPACEfor that namespace (or a prefix of it), the relay sends back the matchingNAMESPACEadvertisements — including echoing tracks that have been published to it under that prefix. - Subscribers discover with
SUBSCRIBE_NAMESPACE, asking “what exists under this prefix?” and receivingNAMESPACE/NAMESPACE_DONEin return.
This is what lets a subscriber walk up to a relay tree and find publishers it had no prior knowledge of. (Draft-18 split the old combined SUBSCRIBE_NAMESPACE into separate namespace-discovery and track-subscription messages — another change covered in the draft-18 post. The moqtap guide to namespace discovery walks the flow hands-on.)
Authorization: a relay is a gatekeeper, not just a pipe
Because a relay stands between publisher and subscriber, it’s the natural enforcement point for access control — and the spec makes this a hard requirement: a relay MUST NOT send PUBLISH messages without knowing the client is interested in and authorized to receive the content. Interest and authorization can be established by the subscriber sending SUBSCRIBE_NAMESPACE, or conveyed out of band; either way, “forward first, check later” is not permitted.
Authorization typically travels as an authorization token parameter on the subscribe or fetch request, which the relay (or the origin) validates. A relay that can’t authorize a request answers with REQUEST_ERROR, not objects. This is why an unauthorized error is a distinct, common subscription failure — the relay is doing its job.
Loop prevention
Chain relays into a mesh and you invite routing loops — A subscribes to B, B subscribes to A, an object circulates forever. MoQT heads this off with a couple of structural rules:
- Session-level tracks and namespaces are not forwarded across sessions. A relay uses certain reserved namespaces (those whose first tuple field begins with a period,
.) for its own session-scoped signaling — cluster gossip, node discovery — and MUST NOT forward requests for them to other sessions. That keeps a relay’s internal coordination from leaking into the delivery tree. - Reserved namespaces are protected. The period-prefixed namespace space is reserved for IANA-defined meanings, so applications can’t accidentally collide with relay control traffic.
Between explicit topology (relays are configured to know their upstreams) and these forwarding restrictions, a well-behaved relay mesh doesn’t circulate objects.
Draining gracefully: GOAWAY and REDIRECT
Relays get restarted, drained for deploys, and scaled down. Doing that without dropping every live subscription is a first-class concern in MoQT, and it’s built on GOAWAY and REDIRECT.
The key ordering rule for a relay shutting down: when the relay (acting as a subscriber’s server) is going away, it SHOULD send GOAWAY downstream before it unsubscribes upstream. That sequence matters — it gives downstream subscribers a window to establish a new session and migrate their subscriptions while objects are still flowing, so there’s no gap:
Relay draining: 1. send GOAWAY downstream (optionally with a new URI) 2. downstream subscribers open a new session, re-establish subscriptions 3. only then: relay unsubscribes upstream and closesDraft-18 sharpened this toolkit considerably: GOAWAY can now target an individual request stream (migrate one subscription rather than the whole session), and the new REDIRECT error response lets a relay point a client at a different relay — or the same relay under a different track name — mid-stream. Together these are the building blocks for graceful relay switchover, both on the subscriber side and the publisher side. We covered the wire-level details in the draft-18 changes post.
One more relay-safety rule worth knowing: if a relay detects a malformed track, it must immediately terminate downstream subscriptions with PUBLISH_DONE and reset any fetch streams — it doesn’t get to forward garbage and let the endpoints sort it out.
Relay vs. CDN edge
It’s tempting to map “relay” onto “CDN edge node,” and the analogy mostly holds — both cache, both fan out, both sit between origin and consumer. The differences that matter:
- The unit is an object stream, not a file. A CDN edge caches finished segments; a relay forwards live objects as they’re produced, so a late joiner can be milliseconds behind the live edge instead of a segment-duration behind.
- The relay speaks the application’s subscription semantics. It understands filters, priority, and group order, so it can make per-subscriber delivery decisions an HTTP cache can’t.
- Feature support varies wildly between implementations. Unlike the commodity HTTP-CDN market, MoQT relays (moq-rs, Moxygen, quiche, libquicr, imquic, moqtail, and others) differ in which drafts and messages they support. Our implementation directory tracks who supports what.
Debugging through a relay
The relay’s invisibility is a gift until something breaks, at which point “is it me, the relay, or the publisher?” becomes the whole question. The failure signatures:
- Subscription works against the origin, fails through the relay. Usually authorization (the relay is enforcing a policy the origin didn’t) or a feature gap (the relay doesn’t support the message or draft you’re using).
- Late joiner gets nothing, live joiner works. The objects you asked for aged out of the relay’s cache — a max-cache-duration problem, not a subscription problem.
- Objects arrive in a surprising order. The relay re-scheduled per your subscriber priority; that’s expected, not a bug. Compare against the delivery-order model.
- Subscription drops during a deploy without a clean handoff. The relay didn’t send GOAWAY before draining, or your client ignored it. Check for the GOAWAY / REDIRECT and whether the client acted on it.
The hard part is seeing both sides of the relay at once. moqtap’s proxy/intercept mode is built for exactly this: sit transparently between your client and the relay and watch the client-facing and relay-facing conversations side by side, so you can tell which hop diverged. The moqtap CLI’s intercept and the desktop app both do this — see the proxy-mode guide. And when the question is “does this relay actually conform?”, moqtap’s conformance testing exercises a relay’s message support and lifecycle behavior systematically rather than by trial and error — the relay conformance use case has the details.
Further reading
- draft-ietf-moq-transport-18 — relay requirements are consolidated in the relays section; caching, aggregation, and switchover live there
- Architecture and connecting to relays — the moqtap docs on relay topology and getting on the wire
- MoQT implementation directory — which relays support which drafts and messages
- How MoQT subscriptions work — the per-subscriber requests a relay aggregates
- MoQT delivery order and priority — the scheduler a relay runs per hop
- moq-wg/moq-transport issues — relay behavior is one of the most actively discussed areas
Debugging a relay and need to see both sides of the conversation? moqtap Desktop and the moqtap CLI sit transparently between your app and the relay, decoding every subscription, object, and control message across drafts 07 through 19 — so you can tell in seconds whether the relay or your code is at fault.