How MoQT Subscriptions Work: Filters, Forwarding, and the Subscription Lifecycle
By moqtap team
How MoQT Subscriptions Work
If you come to MoQT from HTTP, the word “subscribe” is a trap. A SUBSCRIBE is not a request for a resource that comes back once. It’s a standing interest in a track: you tell the publisher which slice of the track you want, whether you want it delivered right now or just reserved, and at what priority — and then objects flow until one side decides they’re done.
That standing nature is the whole point. A track is an open-ended, ordered sequence of objects that may run for hours. You almost never want all of it, and you rarely know in advance where it ends. So MoQT front-loads the interesting decisions into the SUBSCRIBE: where do I start, do I want bytes yet, and how important is this stream relative to my others.
This post walks the subscription model as it stands in draft-ietf-moq-transport-18 — the request/response flow, the four filter types, forward state, subscription updates, and the several ways a subscription can end.
The shape of a subscription
At its core the flow is small:
Subscriber Publisher (or Relay) │ │ │ SUBSCRIBE (track, filter, │ │ forward, priority) ───► │ │ │ │ ◄─── SUBSCRIBE_OK (largest loc, │ │ expires, group order, …) │ │ │ │ ◄═══════ objects (streams / │ │ datagrams) ══════ │ │ │ │ REQUEST_UPDATE (narrow range, │ │ flip forward, change priority) ─► │ │ │ │ … STOP_SENDING / PUBLISH_DONE │A publisher MUST send exactly one SUBSCRIBE_OK or REQUEST_ERROR in response to a SUBSCRIBE. After an OK, objects for the matching range arrive on their own unidirectional streams (one per subgroup) or as datagrams, entirely separate from the request itself.
One draft-18 detail worth internalizing: every request carries a Request ID, and the SUBSCRIBE lives on its own bidirectional request stream. The response, any later updates, and the eventual teardown are all keyed to that ID and that stream. This is a real departure from the early drafts, where SUBSCRIBE and a matching UNSUBSCRIBE were discrete messages multiplexed onto one shared control stream. (For the longer arc of how this got refactored, see our protocol evolution post.)
Filters: where does the subscription start?
The filter is the most important field in a SUBSCRIBE, and the one that trips people up. It answers a single question — from which point in the track do I want objects? — and it does so in terms of groups, because a group boundary is the only place it’s safe to start decoding.
Draft-18 defines four filter types:
| Filter | Code | Start location | End? | Use it when |
|---|---|---|---|---|
| Largest Object | 0x2 | {Largest.Group, Largest.Object + 1} | open | You want to join a live track now and get everything from here on |
| Next Group Start | 0x1 | {Largest.Group + 1, 0} | open | You want to join live but only at the next clean group boundary (next IDR) |
| AbsoluteStart | 0x3 | explicit {Group, Object} | open | You want everything from a known point forward |
| AbsoluteRange | 0x4 | explicit {Group, Object} | explicit End Group | You want a bounded window of the track |
Two things to note.
A note on names. The 0x2 filter is Largest Object and 0x1 is Next Group Start — and has been since well before draft-17 (the filter names are identical in draft-17, -18 and -19). Early drafts called 0x2 Latest Object; the rename to Largest Object had settled by around draft-12, and “Largest Location” is the related term for the greatest {group, object} the publisher has produced. If you’re reading older code or blog posts that say Latest Object, map it to Largest Object. (There was never a “Latest Group” filter — 0x1 has always been the next-group-boundary join.) Since draft-19 the filter travels in the LOCATION_FILTER parameter, renamed from SUBSCRIPTION_FILTER.
The difference between the two live filters is subtle and matters. Largest Object starts you at the very next object the publisher emits — which may land you in the middle of a group, on a frame that depends on an object you never received. For raw media that means you subscribe, get bytes immediately, but can’t decode until the next random-access point. Next Group Start trades a little latency (you wait for the next group to begin) for a clean join: object 0 of a group is, by convention, the IDR. Players that want to render on the first object they receive should prefer Next Group Start.
AbsoluteStart and AbsoluteRange are the “I know exactly what I want” filters. An AbsoluteStart of {0, 0} is equivalent to an unfiltered subscription — give me the whole track from the beginning (assuming the publisher or relay still has it). AbsoluteRange adds an End Group; per the spec, an End Group Delta of zero means “the remainder of that group passes the filter,” so you can request “groups 100 through 105 inclusive” cleanly.
A subtle correctness point: filters describe an intent, and the publisher’s SUBSCRIBE_OK tells you the reality. If you ask for AbsoluteStart {50, 0} but the earliest group the relay still holds is 90, you’ll be told the largest location and you may simply never see groups 50–89. Filters don’t conjure data that has aged out of cache.
Forward state: subscribe without receiving
Every established subscription has a Forward State of 0 or 1. When it’s 1, objects flow. When it’s 0, the subscription exists and is fully negotiated, but the publisher sends no objects.
That sounds pointless until you see what it’s for. Forward-off subscriptions let a subscriber register interest and establish authorization without paying for bytes it isn’t ready to render:
- A player pre-subscribes to several quality variants at
forward: 0, then flips exactly one toforward: 1— an instant quality switch with no fresh SUBSCRIBE round-trip and no relay re-authorization. - A monitoring client subscribes to hundreds of tracks forward-off just to receive their catalog/status updates, forwarding-on only the handful it wants to actually pull.
The initiator sets the initial Forward State in the SUBSCRIBE. Either side can flip it later with a REQUEST_UPDATE (see below). It’s one of the most underused levers in the protocol.
Priority and group order come from the subscriber
Two more fields ride along in the SUBSCRIBE and shape how objects arrive rather than which ones:
- Subscriber Priority — how this subscription ranks against your other subscriptions on the same connection. Lower numeric value = higher priority. When your audio and video tracks compete for a congested link, this is the knob that says “audio first.”
- Group Order — whether groups should be delivered Ascending (oldest first, the default for live) or Descending (newest first, useful when catching up and you’d rather have the latest). The publisher advertises its own preference; the subscriber can request the opposite, and
SUBSCRIBE_OKreports what was actually chosen.
Both of these are inputs to the delivery scheduler, which is a topic large enough for its own post — see MoQT delivery order and priority for how subscriber priority, publisher priority, and group order combine into an actual send order.
What SUBSCRIBE_OK tells you
The SUBSCRIBE_OK is not a bare acknowledgement — it’s a status snapshot of the track at the moment you subscribed:
- Largest Location — the greatest
{group, object}the publisher has produced so far. This is how you learn where “now” is, and it’s what theLargest Object/Next Group Startfilters are computed against. - Expires — how long the subscription is valid before it must be refreshed (0 means it doesn’t expire on its own).
- Group Order — the order that was actually selected, after reconciling publisher and subscriber preference.
- Track Properties — per-track metadata the subscriber needs, carried in the trailing properties block that draft-18 attached to
REQUEST_OK-family responses.
If the publisher can’t or won’t serve the subscription, you get a REQUEST_ERROR instead, with an error code explaining why (unauthorized, track doesn’t exist, invalid range, and so on — see the error code reference).
Changing your mind: REQUEST_UPDATE
A subscription isn’t frozen at creation. REQUEST_UPDATE (the draft-18 generalization of the old SUBSCRIBE_UPDATE) lets the subscriber modify an established subscription in place, without tearing it down and re-subscribing. The common moves:
- Flip Forward State — turn delivery on or off (the quality-switch pattern above).
- Narrow the range — pull in the End Group so the subscription completes sooner. Updates can tighten a subscription but not arbitrarily widen it; you can’t use an update to reach back to data you didn’t originally ask for.
- Change Subscriber Priority — re-rank this subscription against your others as conditions change.
The key property is continuity: the object stream keeps flowing across the update. There’s no gap, no renegotiation of authorization, no new stream. That’s why forward-toggling makes such a clean quality switch.
How subscriptions end
There are several distinct endings, and conflating them is a classic source of bugs.
The subscriber is done. In draft-18 the subscriber ends a subscription by resetting the request — sending STOP_SENDING on the subscribe stream. This replaced the standalone UNSUBSCRIBE message of earlier drafts: since each subscription now owns a bidirectional stream, cancelling the stream is the unsubscribe. Objects stop; the subscription is gone.
The publisher is done. The publisher ends a subscription with PUBLISH_DONE and closes the object stream. Crucially, PUBLISH_DONE does not always mean “state can be destroyed immediately” — objects already in flight on other streams may still be arriving. It’s an announcement that no new objects will come, not a hard stop.
PUBLISH_DONE carries a status code explaining why, and a few of these are worth knowing because they drive very different client behavior:
- Subscription ended normally — the track or your requested range is simply complete.
- Too far behind — you couldn’t keep up; the publisher/relay gave up rather than buffer unboundedly. The right response is usually to re-subscribe with a fresh live filter, not to retry the same range.
- Expired — the subscription’s lifetime elapsed and wasn’t refreshed.
- Going away — the publisher (often a relay) is shutting down this path; pair this with the redirect/GOAWAY machinery to migrate.
(Draft-18 notably swapped the numeric codes for “too far behind” and “expired” relative to draft-17 — a quiet trap for any status-code translation layer, which we flagged in the draft-18 changes post.)
The session is going away. GOAWAY operates above individual subscriptions — it tells the peer to migrate its subscriptions to a new session. A relay draining a node sends GOAWAY downstream before it unsubscribes upstream, so subscribers can re-establish elsewhere without a gap. This is core relay behavior; we cover it in how MoQT relays work.
SUBSCRIBE is for the future; FETCH is for the past
One last distinction, because people reach for SUBSCRIBE when they want FETCH. SUBSCRIBE expresses interest in the live and future portion of a track — even an AbsoluteRange subscription is fundamentally “deliver these groups as they become available.” If what you want is a bounded chunk of already-produced objects — a replay, a seek, backfill after a gap — that’s FETCH, which is a request/response over historical objects served from cache. The two compose: fetch the recent past to prime a buffer, subscribe to the live edge to keep it full. The relay’s cache is what makes both work, and that’s the subject of the relays post.
Debugging subscriptions
When a subscription misbehaves, the failure almost always lands in one of these buckets:
REQUEST_ERRORinstead ofSUBSCRIBE_OK. Read the error code. Unauthorized and “track does not exist” are the common two, and they mean very different things — one is an auth problem, the other a namespace/name mismatch.SUBSCRIBE_OK, but no objects. Check Forward State first. Aforward: 0subscription is working as designed and will never deliver until you flip it. If forward is on, the publisher may simply have nothing new — compare the Largest Location in the OK against what you expected.- Objects arrive but won’t decode. You subscribed with
Largest Objectand joined mid-group. Switch toNext Group Startso you land on an IDR. - Subscription dies with “too far behind.” You’re not draining fast enough, or a subscriber-priority misconfiguration is starving this track. Re-subscribe live rather than re-requesting the stale range.
- Requested an AbsoluteRange you never fully receive. The early part of the range has aged out of the relay’s cache. The range was valid; the data is gone.
moqtap makes every one of these visible. The desktop app and the WebTransport Inspector extension decode the SUBSCRIBE, show the exact filter and forward state you sent, surface the SUBSCRIBE_OK / REQUEST_ERROR and its Largest Location, and then render the objects that (do or don’t) follow — so you can see at a glance whether the problem is your filter, your authorization, or the publisher. See the debugging use case for the full workflow.
Further reading
- draft-ietf-moq-transport-18 — the current draft; subscription filters and the request lifecycle live in the session and control-message sections
- Subscribing to tracks — the hands-on moqtap guide
- Control messages and the message-type reference — field-level detail across drafts 07–19
- MoQT objects, groups, and subgroups — why filters operate on group boundaries
- MoQT delivery order and priority — what subscriber priority and group order actually do to the byte order
- How MoQT relays work — how one upstream subscription fans out to many downstream subscribers
Building on MoQT and want to watch your own subscriptions on the wire? Install the WebTransport Inspector or moqtap Desktop — both decode SUBSCRIBE, SUBSCRIBE_OK, updates, and every object that follows, across drafts 07 through 19.