Media over QUIC (MoQ) Relay

Trickster relays Media over QUIC Transport (MoQT) as a caching relay. Clients subscribe to tracks through Trickster, which holds one subscription per track toward its origin and fans each object out to every subscriber. It keeps the newest groups in memory and older ones in a cache, so FETCH and late joiners are served from Trickster with only the missing objects fetched upstream. Publishers can also send tracks straight to Trickster, making it the origin.

MoQT is a live-media transport: a track is a sequence of groups (for video, typically one group per GOP), each a sequence of objects, delivered over QUIC streams or datagrams. Trickster never parses the media inside objects; it caches and forwards them as opaque payloads.

Where Trickster fits

A publisher packages media into tracks and publishes them to an origin relay. A mid-tier Trickster subscribes to the origin and serves everything below it: viewers directly, or an optional edge tier of more Trickster nodes nearer the viewers. An edge node is a moq backend whose origin_url names a mid-tier node’s moq listener. Each tier holds one upstream subscription per track for all of its subscribers, with a live edge and a cache of its own, so a tier adds capacity near the viewers while the tier above it sees one subscriber per node.

Two variations each change one part of this picture, and combine with it and with each other:

  • Publishing: publishers send tracks straight to Trickster, which takes the origin relay’s place.
  • Origin pools: a Trickster reaches several origins through an alb, which places each track on one of them and fails over between them.

Compatibility

MoQT is still an Internet-Draft, and implementations in the field speak different drafts. Trickster speaks drafts -17 through -22 and negotiates one per session; a session on one draft can relay a track whose origin speaks another.

DraftALPNNotes
-22moqt-22the newest and the preferred draft; LOCATION_FILTER carries its type
-21moqt-21editorial changes only
-20moqt-20fill fetch streams replace Joining FETCH
-19moqt-19range filters, MAX_REQUEST_UPDATES, multiple subscriptions per track
-18moqt-18moqt:// URIs, SUBSCRIBE_TRACKS, REDIRECT
-17moqt-17Required Request ID, PUBLISH_OK, the combined SUBSCRIBE_NAMESPACE

Native QUIC clients negotiate the draft by ALPN, and WebTransport clients by WT-Available-Protocols. Trickster prefers the newest draft, and offers origins every draft, newest first. moq-dev’s relay before 0.17.3 and CLI before 0.14.3 encode -22 as -21; to work with those releases, list -21 ahead of -22 in drafts and upstream.drafts. Drafts before -17 are not supported.

Interoperability is tested on every draft against moq-dev’s relay (0.17.5) and moq CLI (0.14.5), including FETCH.

How clients reach the relay

  • Native QUIC. A listener with protocol: moq accepts MoQT sessions on its UDP port. A session is routed to a moq backend by the AUTHORITY and PATH of its SETUP, matched against the backend’s hosts and paths.
  • WebTransport. Browsers open WebTransport sessions on an HTTP listener that serves HTTP/3. A moq backend’s paths become WebTransport routes there, matched like any other route by host and path.
  • Publishers use either path, with PUBLISH or PUBLISH_NAMESPACE, when the backend accepts them.

All three meet in the backend’s relay, which is shared by every listener the backend is bound to.

Configuration

listeners:
  # native QUIC; TLS is part of the QUIC handshake, so there is no tls_port
  moq1:
    protocol: moq
    port: 443
  # browsers arrive by WebTransport over the default listener's HTTP/3 endpoint
  default:
    port: 8480
    tls_port: 8443
    http3:
      enabled: true

backends:
  live:
    provider: moq
    # moqt:// for a native QUIC origin, https:// for a WebTransport one; omit it to serve
    # only what publishers send
    origin_url: moqt://origin.example.com:443/live
    listener_names: [moq1, default]
    hosts: [live.example.com]
    cache_name: default
    max_ttl: 1h
    tls:
      # served to clients of both listeners
      full_chain_cert_path: /etc/trickster/tls/live.crt
      private_key_path: /etc/trickster/tls/live.key
      # trusted toward the origin
      certificate_authority_paths: [/etc/trickster/tls/origin-ca.crt]

example.full.yaml documents every option.

The moq listener

OptionDefaultDescription
port, addressthe UDP port and address to bind
moq.handshake_timeout5sthe time a client has to send SETUP
moq.idle_timeout2mthe QUIC idle timeout
moq.max_sessions10000sessions served at once; past it, a new connection is closed (“session limit reached”)
moq.max_requests_per_session1024requests a client may have open at once; past it, a request is refused with EXCESSIVE_LOAD
moq.max_filter_ranges, moq.max_request_updates16, 64advertised to clients in SETUP
moq.drafts[22, 21, 20, 19, 18, 17]accepted drafts, most preferred first
moq.implementationtrickster/<version>the MOQT_IMPLEMENTATION setup option
moq.goaway_urithe moqt:// URI a GOAWAY names when the listener stops; see Reloads and shutdown

A moq listener needs a certificate. It takes one from the tls block of a backend it serves, or at runtime with tls_runtime_certs: true, as the Kubernetes controller sets. Certificate files are watched and rotated as for any listener (tls.md).

A moq listener may share its UDP address and port with the HTTP/3 endpoint of an HTTPS listener, or with another moq listener, as long as they serve different ALPNs. Each QUIC connection goes to the listener whose protocol it negotiated, and each keeps its own certificates and routes. A moq listener never shares a port with a protocol: udp listener.

The listener’s ip_acl_name is judged on the client’s first packet, before its TLS handshake, so a refused client costs no handshake; drop and reject both refuse it there, since QUIC cannot leave a client unanswered. Its decisions are counted in trickster_ip_acl_decisions_total with scope="listener". The listener’s rate_limiter_name (see Limits) is judged once the handshake has verified the client’s address. Once the session’s SETUP routes it to a backend, it is judged by that backend’s ip_acl_name (counted with scope="backend") and geo_acl_name, so backends sharing a listener keep their own lists. A session one refuses closes with UNAUTHORIZED, and is logged at debug.

A reload judges the sessions already open by the new lists: the listener’s ip_acl_name, and the ip_acl_name and geo_acl_name of each session’s backend, over native QUIC and WebTransport alike. A session the new lists refuse is closed with UNAUTHORIZED. A WebTransport session is not judged again by its HTTPS listener’s lists. The listener’s path_normalization applies to the PATH of a SETUP before it is routed, and a path it refuses closes the session with MALFORMED_PATH. Settings that shape TCP connections and HTTP requests (connections_limit, the request timeouts and body limits, max_header_bytes, proxy_protocol, trusted_proxies) have no effect on a moq listener, and are warned of at load.

Every path of a moq backend reaches the same tracks, whichever path a session named, so a path cannot carry its own ip_acl_name, geo_acl_name, authenticator_name or rate_limiter_name: one would guard nothing a session on another path could not reach. Serve tracks that need other lists from a backend of their own, or grant them by namespace with tokens. A path with a handler of its own answers HTTP requests, and keeps its lists. A backend served by a moq listener cannot name an authenticator_name either, since a native session carries no HTTP credentials; authorize its sessions with tokens. cors, response_headers and mirrors have no effect on a session, and a request rewriter or request_headers reaches only a WebTransport session’s CONNECT; each is warned of at load.

The moq backend

OptionDefaultDescription
origin_urlmoqt://host[:port]/path (native QUIC) or https://host[:port]/path (WebTransport); optional
hosts, pathsany, /the AUTHORITY and PATH, or the WebTransport request’s host and path, the backend serves
cache_namedefaultthe cache older groups spill to
max_ttl25hthe most a spilled group lives, and its lifetime when its track sets no MAX_CACHE_DURATION
negative_cache_namedefaultthe negative cache whose 404 TTL keeps an origin’s DOES_NOT_EXIST refusal of a track; see Caching
tlsthe certificate served to clients, and the origin’s verification
moq.upstream.alban alb of moq backends to reach the origins through, in place of origin_url; see Origin pools
moq.upstream.drafts[22, 21, 20, 19, 18, 17]drafts offered to the origin, most preferred first
moq.upstream.prewarmfalsekeeps the upstream subscription forwarding while every subscriber is paused
moq.upstream.reconnect_backoff1sthe first wait before re-subscribing a lost upstream subscription
moq.upstream.linger5show long an upstream subscription stays after its last subscriber leaves
moq.upstream.max_gap_fetches16upstream fetches one FETCH or fill may issue
moq.live_edge.groups3groups of each track kept in memory
moq.live_edge.max_size_bytes64MiBpayload bytes the live edge holds across the backend’s tracks
moq.spilltruewrites groups leaving the live edge, and groups fetched upstream, to cache_name
moq.fill_timeout2sthe wait for upstream fetches when a FETCH or fill names no FILL_TIMEOUT
moq.max_object_size_bytes64MiBthe largest object; a larger one resets its stream
moq.webtransport.*see WebTransport and browsers
moq.publish.*see Publishing
moq.authorization.*see Authorization

A moq backend without origin_url (or upstream.alb) has no origin. It serves what publishers send, and validation warns when it accepts no publishers either. With an origin, the health check completes a SETUP exchange with it on a fresh session; an origin-less backend is left unprobed.

Routing sessions

A native session names its AUTHORITY and PATH in SETUP; when it names no authority, the TLS server name stands in, and when it names no path, /. Among the backends on the listener, one naming the host exactly wins over one naming it by wildcard, which wins over one naming no host. Among those, the longest segment-aligned path prefix wins. A listener with one backend serves every session with it, and a session no backend matches is closed with INVALID_PATH. No two backends on a listener may claim the same host and path.

WebTransport and browsers

A moq backend bound to an HTTP listener serves WebTransport sessions on that listener’s HTTP/3 endpoint (http3.md); validation warns when the listener has none. The endpoint switches to WebTransport only while a moq backend uses the listener, and a reload that binds or unbinds one changes how new connections are served without restarting the endpoint.

Each of the backend’s paths accepts WebTransport sessions at and below it (a regex path matches as a pattern), and a backend with no paths accepts them at /. The usual HTTP routing applies: on a listener shared with other backends, give the moq backend hosts or make it is_default, or reach it under its name (https://host/<backend>/). The CONNECT request runs through the route’s middleware, so authenticator_name, IP and geo ACLs, metrics and access logs all apply to it.

OptionDefaultDescription
moq.webtransport.drafts[22, 21, 20, 19, 18, 17]drafts accepted from WebTransport clients, most preferred first
moq.webtransport.default_draft0the draft assumed for a client that offers no WT-Available-Protocols, and for a WebTransport origin that answers without WT-Protocol; 0 refuses both
moq.webtransport.allowed_originsbrowser origins (scheme://host[:port]) that may open sessions, or *; empty allows only an origin naming the requested host, and a client that sends no Origin is always allowed
moq.webtransport.max_sessions10000WebTransport sessions the backend serves at once
moq.webtransport.goaway_urithe https:// URI a GOAWAY names when the listener stops

Trickster answers 403 for a refused origin, 405 for a method other than CONNECT, 400 for a CONNECT that is not WebTransport or offers no usable draft, and 503 past max_sessions. WebTransport sessions take the moq listener defaults for their limits. Chrome 143, Firefox 155 and Safari 26.4 send WT-Available-Protocols through the WebTransport protocols option; a browser needs a certificate it trusts.

Toward an https:// origin, backends that reach the same host and port with the same tls settings and drafts carry their WebTransport sessions on one QUIC connection, each backend with its own session for its own path. The connection offers WebTransport flow control, which an origin must also enable to take several sessions on one connection. An origin that refuses a second session is dialed one connection per session from then on. The connection closes with its last session. Native moqt:// origins get a connection per backend, since a native connection is a single session.

Caching

Each track’s newest live_edge.groups groups stay in memory, within a backend-wide live_edge.max_size_bytes budget that evicts the oldest groups first. Objects are stored once and forwarded cut-through as their bytes arrive, so every subscriber and the cache share one copy. A group leaving the live edge spills to cache_name as one entry, by reference in a memory cache. Its key includes the backend’s name, so backends sharing a cache never read or overwrite each other’s groups, even for tracks of the same name. It lives for the track’s MAX_CACHE_DURATION less the oldest object’s age, capped by max_ttl. An object past its MAX_CACHE_DURATION is unknown again in both tiers and is not forwarded. Spilled groups are indexed in memory, so after a restart a persistent cache’s groups are fetched from the origin again rather than found. A track found malformed (a duplicate object differing from the first copy, or an object past the end of its group or track) is dropped uncached and its subscribers end with MALFORMED_TRACK.

An origin’s DOES_NOT_EXIST refusal of a track is MoQ’s 404. It is kept for the 404 TTL of the backend’s negative_cache_name, and answers SUBSCRIBE, FETCH and TRACK_STATUS for the track without asking the origin again. The default negative cache is empty, so nothing is kept unless configured:

negative_caches:
  default:
    '404': 3s

Only the origin’s refusal is kept. A namespace publisher’s is not, and neither are codes that depend on the requester or pass on their own (UNAUTHORIZED, the token codes, TIMEOUT, INTERNAL_ERROR, EXCESSIVE_LOAD). A PUBLISH of the track, or a namespace arriving that covers it, forgets the refusal at once, so a stream that starts after its viewers ask is served as soon as it is published or announced. A backend keeps at most 4096 refusals. Clients that keep asking for names that do not exist are a matter for rate limiting.

FETCH and fills (-20 and later) are served from the live edge and the cache where a group is known in full. Each run of groups between them is fetched upstream with one request, in the requested group order, and cached for the next request. Past max_gap_fetches requests, the remaining gaps are reported as an End of Unknown Range. A group is known in full only when every object in the requested range is present or known absent. A publisher that never signals the end of its groups therefore has past groups fetched upstream rather than answered incompletely. FILL_TIMEOUT (default fill_timeout, at most 30 seconds) bounds the upstream wait. Past it, the rest of the range is reported as an End of Timed-Out Range, and an unreachable origin as an End of Unknown Range. A FETCH that names no group order is served ascending, as the drafts require, whatever the track’s publisher prefers. Joining FETCH, on drafts through -19, is served the same way.

Subscriptions

One upstream subscription per track carries every downstream subscription to it, each filtered at the relay by its own location window and range filters. A new subscription first gets the newest group the relay holds, from the start of its window, and then what arrives. A player joining at the current group, as moq-dev’s does with a Relative Start of 1, so gets a catalog that was published once, and a subscription starting at the Next Object gets only what arrives. Older groups come by fill or FETCH. Draft -22 §3.5 has a joiner ask for the current group with a fill; the relay delivers it either way. SUBSCRIBE_OK waits until the upstream subscription is established. Forwarding follows the subscribers: the upstream subscription forwards while any subscriber does, and pauses otherwise unless upstream.prewarm keeps it forwarding. A lost upstream subscription is re-subscribed with doubling backoff, up to five attempts, while the downstream subscriptions stay open. That includes one the origin ends with UPDATE_FAILED for refusing the relay’s own update, such as a resume or an EXPIRES renewal; should the retries fail, its subscribers see INTERNAL_ERROR, since the update was never theirs. Only when that fails do they end with PUBLISH_DONE. An upstream subscription lingers for upstream.linger after its last subscriber leaves, so a returning viewer finds it warm.

When the upstream gives an EXPIRES, the relay asks to extend the subscription before it elapses, with an empty REQUEST_UPDATE at three quarters of the time given. A new EXPIRES in the answer schedules the next renewal; an answer without one, such as to a FORWARD change, leaves the pending renewal as it was. The relay never ends a downstream subscription on a timer, so it sends no EXPIRES downstream.

NEW_GROUP_REQUEST reaches the publisher as the spec requires of relays:

  • with the upstream SUBSCRIBE while the track has no established subscription;
  • afterwards, as a REQUEST_UPDATE to every source, but only for a value of 0 or one past the Largest Group, on a track whose DYNAMIC_GROUPS is 1, and only while no request at least as large is outstanding. A request is outstanding until the Largest Group increases.

Publishing

With moq.publish.enabled, a backend accepts PUBLISH and PUBLISH_NAMESPACE and becomes the origin of what publishers send. Open ingest belongs behind a network boundary or authorization.

Here Trickster takes the origin relay’s place in Where Trickster fits. Its subscribers, and any tiers below it, are served as they would be from a pulled track.

OptionDefaultDescription
moq.publish.enabledfalseaccepts publishers
moq.publish.namespacesanynamespace prefixes publishers may claim, as fields joined by / (live/studio)
moq.publish.max_publishers_per_track4publishers of one track at once; 0 allows any number

A track can have several sources at once: publishers’ PUBLISHes, and the subscriptions the relay opens toward every session that published a matching namespace, or toward the origin when none did. The first copy of each object wins, an identical duplicate is dropped, and a differing one makes the track malformed. A publisher can take over from another without its subscribers noticing, and one whose session drops can return on a new session before the relay gives up on the track. The relay never pauses a publisher, so an ingest backend’s cache keeps everything published.

SUBSCRIBE_NAMESPACE is answered with the namespaces publishers announced, and kept current as they come and go. A backend with an origin also learns the origin’s namespaces, in either of two ways, and announces a namespace once however many sources report it:

  • It forwards the request to the origin. What an origin reports is backend-wide, so every scope shares the upstream requests, and their prefixes never overlap, as one session requires: a prefix that an upstream request covers shares it, and a wider one replaces the narrower requests it covers. A replaced request’s namespaces stay announced while the wider one reports them again, so subscribers see no flap. A lost request withdraws what it reported and is sent again. One the origin refuses is not retried, except a PREFIX_OVERLAP refusal, which the replacement can race. One whose last subscriber leaves lingers for upstream.linger.
  • It accepts PUBLISH_NAMESPACE from the origin on its upstream session, as moq-dev’s relay sends, and reports those namespaces until the origin withdraws them or the session ends.

SUBSCRIBE_TRACKS offers each published track under the prefix with a relay PUBLISH. A backend with an origin forwards it the same way, shared by prefix (as SUBSCRIBE_NAMESPACE with the PUBLISH option on draft -17). Each PUBLISH the origin sends in answer becomes a source of its track and is offered to every matching subscriber, in every scope. A subscriber in another scope is served the track pulled from the origin with SUBSCRIBE. An origin PUBLISH that no subscription covers is refused with UNINTERESTED.

A SUBSCRIBE naming a RENDEZVOUS_TIMEOUT waits up to that long (at most 30 seconds) for a publisher to arrive; without one, a track nobody publishes is refused with DOES_NOT_EXIST. Publishers are refused with:

  • NOT_SUPPORTED when the backend takes no publishers;
  • UNAUTHORIZED outside publish.namespaces, or without a granted token;
  • EXCESSIVE_LOAD past max_publishers_per_track.

Authorization

moq.authorization.mode is none (the default) or token. In token mode, every request needs an AUTHORIZATION_TOKEN that token_file grants, sent in the request or in the session’s SETUP.

# <token> <publish|subscribe|any> [namespace]
studio-encoder publish live/studio
viewer-7f3a subscribe live
ops-tools any

SUBSCRIBE, FETCH, TRACK_STATUS, SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS need a subscribe grant, and PUBLISH and PUBLISH_NAMESPACE a publish grant. A grant’s namespace must prefix the request’s, so a grant for live covers live/studio but not vod. A grant with no namespace covers every namespace. The file is read again when it changes, checked at most once a second; a file that no longer parses keeps its last grants, and one that cannot be read at load fails the backend. A WebTransport CONNECT is also subject to the route’s authenticator_name, before any MoQT request is made. A native session carries no HTTP credentials, so a backend that a moq listener serves cannot name an authenticator, and tokens are how its sessions are authorized.

Origin pools

moq.upstream.alb names an alb whose pool members are moq backends with origins. The routed backend keeps its relay, cache, live edge, WebTransport, publishing and authorization, and reaches its origins through the alb, which picks one member per track:

backends:
  live:
    provider: moq
    listener_names: [moq1]
    moq:
      upstream:
        alb: origins
  origins:
    provider: alb
    alb:
      mechanism: hrw
      pool: [origin-a, origin-b]
  origin-a:
    provider: moq
    origin_url: moqt://origin-a.example.com/
  origin-b:
    provider: moq
    origin_url: moqt://origin-b.example.com/

The diagram shows this configuration, all in one Trickster process: viewers subscribe to live, whose relay reaches Origin A and Origin B through the origins alb. In Where Trickster fits, it replaces the mid-tier’s single origin.

The mechanism must pick a single member: rr, p2c, hrw (each track placed by its full name) or lc. When a track’s origin fails, its upstream subscription moves to another member and its subscribers see no PUBLISH_DONE. A member that fails its health check is passed over until it recovers, and stream.passive_health on the alb ejects members whose origins cannot be dialed. Pool members must be moq backends with origins; nested albs and discovery are refused.

The routed backend dials each member on a session of its own, apart from the member backend’s own relay, so the two never share a Track Alias. A SUBSCRIBE_NAMESPACE or SUBSCRIBE_TRACKS it forwards goes to the member the prefix places, and what that member’s origin publishes on the session reaches the routed backend’s subscribers.

A member listed on an HTTP listener is reachable there at /<member name>/, as any backend is, and judged by its own lists rather than the routed backend’s; give members path_routing_disabled: true so only the routed backend serves sessions. The Kubernetes controller does so for the members it generates.

Priorities

MoQT orders objects by subscriber priority, then publisher priority, then group order (each 0 to 255, lower first). QUIC offers eight stream urgencies (RFC 9218), so Trickster maps the two priorities onto urgencies 1 to 6:

Subscriber priorityPublisher priority below 128Publisher priority 128 and above
0–6312
64–19134
192–25556

Control and request streams take urgency 0. Within an urgency, an ascending subscription’s streams are sent oldest first, while a descending subscription’s streams share bandwidth so its newest group keeps moving. Datagrams have no priority, and WebTransport streams take none either, since webtransport-go exposes no stream urgency.

Reloads and shutdown

A reload never ends a session. A session keeps the backend it was routed to for its life, even when a reload removes that backend; only new sessions see the new configuration. Each backend’s relay (its live edge, upstream subscriptions and upstream session) carries over to the reloaded backend while its relay settings are unchanged. A reload that changes them starts a new relay for new sessions, and the old one serves its sessions to their end. Publishing and authorization policy takes effect when a reload commits. A reload restarts a moq listener only when its address, port or moq block changes; its sessions are then drained as at shutdown.

When Trickster stops, each session gets a GOAWAY naming the listener’s goaway_uri (or the backend’s webtransport.goaway_uri), with the drain’s remaining time as its timeout. New requests on a departing session are answered with REDIRECT to that URI on drafts that have it (-18 and later), or refused with GOING_AWAY. Sessions still open when the drain ends are closed with GOAWAY_TIMEOUT.

Limits

LimitValue
sessions per listenermoq.max_sessions (10000)
open requests per sessionmoq.max_requests_per_session (1024), then EXCESSIVE_LOAD with a 1-second retry
WebTransport sessions per backendmoq.webtransport.max_sessions (10000)
object sizemoq.max_object_size_bytes (64 MiB)
live edgemoq.live_edge.max_size_bytes (64 MiB) per backend
upstream fetches per FETCH or fillmoq.upstream.max_gap_fetches (16)
upstream wait for a FETCH or fillFILL_TIMEOUT, at most 30 seconds
rendezvous waitRENDEZVOUS_TIMEOUT, at most 30 seconds
data streams waiting for an unknown track alias64 per session, each for at most 2 seconds
namespaces32 fields and 4096 bytes
remembered DOES_NOT_EXIST refusals4096 per backend

Rate limiters keyed on client_ip, or on no key for one bucket, limit clients by address:

  • A moq listener’s rate_limiter_name counts its sessions (unit sessions), each judged when its connection arrives, after the listener’s ip_acl_name. A session over the limit is closed before it begins.
  • A moq backend’s rate_limiter_name counts the SUBSCRIBE, FETCH and TRACK_STATUS requests of its sessions (unit requests), native and WebTransport alike, so a client’s sessions share one budget. A request over the limit is refused with EXCESSIVE_LOAD, and a Retry Interval of the wait until it could pass.
  • WebTransport sessions also pass the HTTPS listener’s limiter, and the backend’s counts each CONNECT as a request too.
rate_limiters:
  moq-sessions:
    keys: [client_ip]
    limit: 20
    window: 1m
  moq-requests:
    keys: [client_ip]
    limit: 600
    window: 1m
listeners:
  moq1:
    protocol: moq
    port: 443
    rate_limiter_name: moq-sessions
backends:
  live:
    provider: moq
    rate_limiter_name: moq-requests

Only client_ip is a key a MoQ limiter can read, and close is not an action it takes. Their decisions are counted in trickster_ratelimit_decisions_total with plane="moq".

Metrics and logs

MetricLabels
trickster_moq_sessions_totallistener_name, backend_name, role, transport (quic, webtransport), draft, result (ok, refused, no_route, busy, failed)
trickster_moq_active_sessionslistener_name, backend_name, role
trickster_moq_requests_totalbackend_name, type, result
trickster_moq_active_subscriptionsbackend_name, direction (upstream, downstream)
trickster_moq_objects_totalbackend_name, direction (in, out), delivery (stream, datagram, fetch)
trickster_moq_bytes_totalbackend_name, direction
trickster_moq_dropped_objects_totalbackend_name, reason (oversize, delivery_timeout, datagram_queue_full, spill_queue_full)
trickster_moq_fetch_objects_totalbackend_name, source (live_edge, cache, upstream)
trickster_moq_live_edge_bytesbackend_name
trickster_moq_publishers_activebackend_name
trickster_moq_errors_totalbackend_name, class

Track and namespace names are never labels. Spilled groups count in the cache’s own trickster_cache_* metrics. Each session’s close is logged at info with its listener, backend, draft, transport, client, duration and reason.

A native session writes one line to its backend’s access_log when it ends, as a request does, with the method SETUP, the session’s PATH as the path, its AUTHORITY as the host, the negotiated ALPN (such as moqt-22) as the protocol, and the session’s duration; it has no status, which is logged as 0. With the backend’s tracing_name, each native session is one moq session span carrying moq.backend, moq.authority, moq.path and client.address, and moq.end with why it ended. A WebTransport session is logged and traced as its CONNECT request, and since a CONNECT lasts as long as its session, it is counted in trickster_frontend_requests_total but not timed in trickster_frontend_requests_duration_seconds.

A moq backend’s health is served at /trickster/health/<backend name> on the metrics listener, as the result of its origin probe. Purging by path does not apply to a moq backend, whose cache entries are groups of tracks rather than responses to paths, and is refused; spilled groups expire by max_ttl.

Kubernetes

The Kubernetes controller serves MoQT through a Gateway listener with the protocol trickstercache.org/moq and UDPRoutes, and WebTransport through an HTTPRoute whose TricksterCachePolicy names provider: moq. See kubernetes-gateway.md.

Trying it locally

One command runs a harness: moq-dev’s relay as the origin, hack/diagstreamer publishing a live test pattern into it, and Trickster in the foreground with docs/developer/environment/trickster-config/moq.yaml. A second opens a player page, which Trickster serves at https://localhost:8583/player/, in a Chrome profile that trusts the development certificate. The page lists the broadcasts the relay announces and plays the selected one with moq-dev’s <moq-watch> element, loaded from a CDN:

make developer-moq
make developer-moq-browser

ARGS passes flags to Trickster, as in make developer-moq ARGS="-log-level debug". The harness’s Trickster listens on ports 8580 to 8590, clear of make serve-dev’s, so the two can run side by side. The integration stack’s relay publishes the same UDP port as the harness’s, so stop it first if it is running.

The developer environment’s moq profile runs moq-dev’s relay on UDP 10443, which serves as an origin, and make dev-certs writes a self-signed certificate for localhost:

make dev-certs
make developer-start-moq

Point a relay at it, with a native listener on UDP 8490 and WebTransport on the default listener’s HTTP/3 endpoint:

listeners:
  moq1:
    protocol: moq
    port: 8490
  default:
    tls_port: 8483
    http3:
      enabled: true
backends:
  demo:
    provider: moq
    origin_url: moqt://127.0.0.1:10443/
    listener_names: [moq1, default]
    tls:
      full_chain_cert_path: docs/developer/environment/certs/trickster-dev.crt
      private_key_path: docs/developer/environment/certs/trickster-dev.key
      # the dev relay generates its own certificate
      insecure_skip_verify: true

hack/moq-client subscribes, publishes and fetches over native QUIC or WebTransport. It publishes synthetic groups of random bytes behind a small header, which a subscriber checks byte for byte:

# publish a group every second into the dev relay
go run ./hack/moq-client -url moqt://127.0.0.1:10443/ -insecure -pub demo/video

# subscribe through Trickster's moq listener, and by WebTransport under the backend's name
go run ./hack/moq-client -url moqt://localhost:8490/ -ca docs/developer/environment/certs/trickster-dev.crt -sub demo/video
go run ./hack/moq-client -url https://localhost:8483/demo/ -ca docs/developer/environment/certs/trickster-dev.crt -sub demo/video

# fetch groups behind the live edge, by the IDs the subscriber printed
go run ./hack/moq-client -url moqt://localhost:8490/ -ca docs/developer/environment/certs/trickster-dev.crt -fetch demo/video -from 1791148101 -to 1791148103

A published group’s ID is the Unix time it started, so a restarted publisher never reuses a group a relay has cached. -draft offers a single draft, -token sends an AUTHORIZATION_TOKEN, -groups stops after that many groups, and -h lists the rest. Each received object prints verified, unverified (a payload without the synthetic header), or CORRUPT, and the client exits nonzero when any object fails verification.

For real media, publish a fragmented MP4 with moq-dev’s CLI into the dev relay, or into a backend that accepts publishers, and play it in moq-dev’s web player through Trickster’s WebTransport endpoint with a certificate the browser trusts. hack/diagstreamer generates a live feed with a clock and a timing strip in the picture, as an ABR ladder of CMAF tracks with MSF and hang catalogs, and publishes it itself:

go -C hack/diagstreamer run ./cmd/diagstreamer -out moq -moq-url moqt://localhost:8490/ -moq-insecure
moq --connect moqt://localhost:8490/ --broadcast diagstreamer.msf export --catalog-format hang fmp4 > out.mp4

Troubleshooting

  • A session closes with INVALID_PATH: no backend on the listener matches the session’s AUTHORITY and PATH. Check hosts and paths, or bind one backend to the listener.
  • WebTransport answers 404: the CONNECT matched no route. Give the backend hosts, make it is_default, or use the backend’s name as the path prefix.
  • WebTransport answers 400: the browser offered no draft the backend accepts. Check moq.webtransport.drafts, or set default_draft for a client that offers none.
  • A subscription is refused with DOES_NOT_EXIST: the origin does not have the track, or an origin-less backend has no publisher for it.
  • Objects stop and resume: compare trickster_moq_dropped_objects_total by reason, and trickster_moq_active_subscriptions{direction="upstream"} for upstream losses.
  • FETCH reaches the origin every time: the groups never spilled (moq.spill: false, or a cache that refused them, counted in trickster_moq_errors_total{class="cache"}), or the publisher never marks the end of a group.

Known gaps

  • No TCP fallback: WebTransport over HTTP/2 is not served, so clients need UDP.
  • No packaging: Trickster does not convert between MoQT and HLS, DASH or other formats.
  • Upstream subscriptions are never filtered, so MAX_FILTER_RANGES is not used toward origins.
  • On a draft -17 origin, SUBSCRIBE_NAMESPACE asks for namespaces and for PUBLISHes alike, so a forwarded namespace request and a forwarded track request with overlapping prefixes conflict. The second is refused with PREFIX_OVERLAP, and given up after the retries.
  • moq-dev’s relay cannot be a WebTransport origin, because it does not support the reset_stream_at extension that WebTransport over HTTP/3 needs.
  • Stream priorities and reliable reset boundaries are not available over WebTransport (see Priorities).
Last modified October 27, 2026: bump docs for v2.2 (891dcc4)