---
url: https://doc.moq.dev/concept/moq-lite.md
description: The generic pub/sub layer, a simple forward-compatible subset of moq-transport
---

# moq-lite

moq-lite is the pub/sub protocol this project speaks. It is a deliberately
small subset of the IETF [moq-transport](/concept/standard) draft, so it works
against any moq-transport relay (including
[Cloudflare](https://moq.dev/blog/first-cdn)) while staying simple enough to
implement in an afternoon. The wire spec is
[draft-lcurley-moq-lite](/draft/moq-lite).

## Terminology

| moq-lite | Meaning | moq-transport name |
| --- | --- | --- |
| **Session** | One connection, publishing and subscribing at once. | Session |
| **Origin** | The set of broadcasts visible to a session, scoped by the URL path. | (none) |
| **Broadcast** | A named, discoverable collection of tracks from one publisher. | Namespace |
| **Track** | A live sequence of groups, delivered out of order until closed. | Track |
| **Group** | A sequence of frames delivered reliably and in order, on its own QUIC stream. | Group |
| **Frame** | A sized chunk of bytes. | Object |
| **Datagram** | One unreliable frame sent as a QUIC datagram instead of a group. | Datagram |

## Session setup

A dedicated ALPN selects the wire version for moq-lite 03 and newer. The
legacy `moql` ALPN negotiates moq-lite 01 or 02 via `SETUP`. In moq-lite 05
and newer, each side also sends a `SETUP` message with its capabilities.
Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts
14 through 22. Clients offer `moq-lite-06` first by default. moq-lite 07 is
still in progress: it negotiates as `moq-lite-07-wip`, and only when both
sides explicitly enable it. moq-lite 07 also switches every varint from QUIC's
two-bit length prefix to moq-transport's leading-ones form, so values up to 127
take one byte instead of up to 63, and the range widens from 62 to 64 bits. Rust
still refuses lite-07 values above 2^62-1 until its `VarInt` widens.

## Subscription completion

On moq-lite 07, `SUBSCRIBE_END` counts the group streams opened for the
subscription. Rust and TypeScript stop waiting for missing streams once that
many headers have arrived; skipped group sequences add no wait. A stream whose
header arrived is always read to its end before the subscription completes.

A stream reset before its header arrived cannot be counted, so the subscriber
still allows a grace period for late streams. The grace uses the subscription's
nonzero effective maximum age, or one second when no maximum age is set.
moq-lite 05 and 06 instead account for group sequences using received headers,
datagrams, and `SUBSCRIBE_DROP`, from the start group the subscription last
asked for. A lost datagram is not owed, but its hole waits out the grace like a
lost stream.

From moq-lite 06, an end that contradicts the groups received aborts the track:
a group at or past `SUBSCRIBE_END`, or a `SUBSCRIBE_END` below a group already
received. moq-lite 05 specified an inclusive end, so there it only drops that
group or the early boundary.

## Discovery

A session can ask for announcements matching a path prefix. The peer replies
with what it can serve today, then streams changes as they come and go. That is
how a conference room learns who joined, how a player learns a stream came
online without polling, and how [relay clusters](/bin/relay/cluster) discover
each other.

An announcement is a **route**: a claim that broadcasts at a path prefix, and
every path beneath it, can be served. By convention a publisher announces each
broadcast's exact path, so subscribers enumerate broadcasts by listing routes,
but a service can announce one short prefix and serve whatever is requested
beneath it, advertising capability without enumerating inventory. A route is
always a prefix, on every wire version and on moq-transport alike; a service
that serves only some of the paths beneath its prefix refuses the rest as they
are requested. Each route carries the chain of relay identities it passed
through, which is how forwarding loops are caught, and a cost, which is how a
subscriber picks among several routes to the same broadcast. A hop of 0 is the
anonymous mark and travels the chain unchanged; when it is the first hop, a relay
puts a random ID, fresh per connection, in front of it to name the publisher. A route that passed through an
anonymous hop at any depth ranks below every fully identified route, whatever
the costs say; among anonymous routes, cost keeps ordering.

On moq-lite 07, an announcement may copy the head of its path and the tail of
its relay chain from one still live on the same stream, so many broadcasts
from a few origins behind the same relays stop repeating those bytes. Rust
compresses when it helps; TypeScript decodes it but always sends literally.

A broadcast exists only while it is announced, for consumers in the same
process and across a session alike: one that is created but never announced
can be neither discovered nor requested. A broadcast published locally
competes with remote routes to its path on cost like any other route, winning
only a tie. Retracting a route (an unannounce, or the peer's `ANNOUNCE_END`)
stops new requests from resolving through it but leaves subscriptions already
in flight alone: each track runs to its own end or failure. On moq-lite 05 and
newer, a clean end requires `SUBSCRIBE_END` before the publisher's FIN. A FIN
without that declaration fails the subscription with `ProtocolViolation`; older
moq-lite versions use FIN alone. moq-transport requires `PUBLISH_DONE` before FIN.
moq-transport sessions behave the same when a namespace is withdrawn. A route
update that changes its first hop, the original publisher, is not a retraction:
subscriptions in flight drain the old publisher, and new requests resolve
through the new one.

### Hidden broadcasts

A path segment starting with `.` hides a route from discovery, the way a
dotfile hides from `ls`. A platform publishes its own broadcasts there (relay
stats under `.stats/`) without them turning up in an app that lists
everything and plays what it finds. Only segments
below the requested prefix count: listing the root skips `.stats/node`, but
listing `.stats` shows `node`. A `.` elsewhere in a segment (`catalog.pro`) is
part of the name.

Hiding narrows discovery and nothing else. Subscribing to a hidden path by
name works without asking, and tokens authorize it like any other path. To
list hidden routes too, opt in per announce request:

```rust
let announced = origin.consume().with_hidden(true).announced();
```

```typescript
const announced = connection.announced(Path.Pattern.all(), { hidden: true });
```

On the wire, moq-lite 07 (`moq-lite-07-wip`, opt-in only) carries the opt-in
on each announce request, and
moq-transport carries it as a `SUBSCRIBE_NAMESPACE` parameter once the peer's
`SETUP` says it understands one ([hidden](/draft/moq-hidden)). An older MoQ Lite peer
never opts in, so it never discovers hidden routes.
IETF peers that omit the MoQ Hidden setup option receive all authorized
namespaces, including dot-prefixed namespaces. Peers that declare it opt in
per subscription; a prefix naming the dot segment itself also lists its
children. Rust sessions always opt in
on the wire and filter per local reader, so a relay mirrors everything and
each consumer decides.

## Path patterns

Rust's `moq_net::Pattern` and TypeScript's `Path.Pattern` from `@moq/net`
describe sets of literal paths. Patterns match the whole path. `room` matches
only `room`, while `room/**` matches `room` and every descendant. Segments are
separated by `/`:

| Segment | Matches |
| --- | --- |
| `room` | That literal segment. |
| `*` | Exactly one nonempty segment. |
| `camera-*` | One segment starting with `camera-`. |
| `pre*suf` | One segment with that prefix and suffix, without overlapping them. |
| `**` | Zero or more segments. |

A pattern may contain at most 32 segments and one `**`. Leading, trailing,
or repeated separators are invalid pattern syntax. The empty pattern matches
the empty path. There is no escape syntax for a literal `*`.
Construction normalizes adjacent `*` and `**`: `*/**` prints as `**/*`,
so equivalent wildcard placements have the same identity.

Patterns never travel as announcements. `origin.dynamic(prefix, route)`
advertises a prefix: the call claims that `prefix` and every path beneath it
*can* be served, not that any exist. A route is a capability, not an inventory:
a subscriber must not treat a prefix as a concrete broadcast name. Use
`publish(path, route)` when the path is known; use
`dynamic` when the set of paths is not, and refuse the requests you will not
serve. A pattern lives in two places: the token, which scopes what a session
may publish and subscribe to, and a local filter a consumer applies to the
prefixes it is told about.

A subscriber watching under a root sees advertisements named relative to that
root. The pattern scope filters which prefixes are visible without changing a
route's prefix. When several routes advertise one prefix, each reader sees the
best route its scope can use, so a cheaper route scoped elsewhere never hides
it. Announce events carry the covered path, captures, and what
happened to it: Rust
`announce::Update { prefix, captures: Option<Vec<Pattern>>, route, kind }` and
TypeScript `Announce.Update { prefix, captures, route, kind }`, where the kind is
announced, updated (a reprice in place), or retracted. Captures are present when
the announced prefix pins every wildcard in the most-specific matching scope
member. The Rust consumer is a `Stream` and the TypeScript one an async iterable.

Announcements are hints; requests are the authority. When a subscriber asks
for a covered path the advertiser will not serve, the advertiser refuses that
request rather than narrowing the claim, and no message narrows a route. Token
scope is any pattern union; the session asks for each member's literal head on
the prefix-only wire and filters locally. In Rust and TypeScript,
`origin.scope(root, patterns)` narrows the handle's permissions and presents paths
relative to `root`. Nested scopes intersect with their parent. In Rust,
`origin.mount(at, target)` reads the subtree at `at` from `target` instead: a
request for `at/rest` joins the one front at `target/rest`, announcements under
`target` present under `at`, the handle's patterns still authorize `at/rest`,
and nothing is published beneath `at`. Mounts never chain: a mount point that
overlaps another mount's point or any target, its own included, is refused. A session receiving
into that scoped origin asks for the literal heads of its allowed patterns,
coalescing duplicate or nested heads. An unscoped origin still asks for the empty
prefix, covering every namespace. These subscriptions include hidden routes;
each local announcement reader decides whether to show them.

```typescript
import { Path } from "@moq/net";

const scope = Path.Pattern.parse("room/**");
scope.matches("room/alice"); // true
scope.contains(Path.Pattern.parse("room/camera-*")); // true
scope.overlaps(Path.Pattern.parse("*/alice")); // true
scope.rebase("room").toJSON(); // ["**"]
Path.Pattern.parse("camera-*").rooted("room").text; // "room/camera-*"
```

`contains` asks whether every path matched by the other pattern is allowed by
this one. `overlaps` asks whether they share any matching path. `rebase` returns
the matching paths relative to a literal root; `rooted` places a pattern beneath
that root and rejects results exceeding the segment limit. Rebasing can require
several patterns: `**/a` rebased at `a` yields both `""` (the root itself) and
`**/a` (deeper paths ending in `a`).

`Patterns` is a union that removes members contained by another member and
orders the remaining members canonically. Its containment check requires one
member to cover the entire requested pattern; it does not combine several
members to prove joint coverage. Equality compares those reduced members.
Use `specificity` to rank structural constraints when selecting rules, and
`contains` to check whether a rule stays within a scope.

## Subscriptions

A subscriber names a broadcast and track. Delivery starts at the oldest group
it can still use, which at the default budget is the latest one, so every group
must begin at a point a fresh subscriber can decode from (a keyframe, a full
JSON snapshot). Groups can be fetched by sequence number too, optionally
bounded to a range of frames, which is how the [HLS gateway](/bin/hls) and the
relay's [HTTP fetch](/bin/relay/http) serve history.

Each subscription carries the knobs that decide behavior under congestion:

| Knob | Effect |
| --- | --- |
| **Priority** (0..255) | Higher-priority tracks get bandwidth first. Audio above video, base layer above enhancement. |
| **Order** | Which group to send first when several are pending. Newest first for live, oldest first for catch-up. |
| **Max age** | How old a non-latest group may get before it is skipped. Zero means "live edge only", and raising it is also what asks for history. |

Max age is measured on the media timeline, not the wall clock, so a backlog
delivered as a burst is still old while a congestion stall never expires
anything on its own. Both ends apply it: the publisher skips a group rather
than sending it, and the subscriber skips it again as it reads, since the
publisher only ever sees the most tolerant budget across its subscribers.

Across a native route failover, the reader still judges buffered groups against
the logical track's live edge, including groups it is draining from a retired
route. A successor group with no timestamp leaves the preceding group's reach
unbounded until its first frame arrives; if it is dropped first, the next group
takes its place. A cached open group's prefix remains
readable across repeated takeovers and idle resumes.

The publisher declares a retention window per track, which bounds how far back
a fetch or late subscriber can reach. Media tracks default to 30 seconds so a
segmented egress can still find its segments.

Put together, a conference might use:

| Track | Priority | Order | Max age |
| --- | --- | --- | --- |
| audio | 100 | ascending | 500 ms |
| video | 50 | descending | 2 s |

Under light congestion video drops the tail of a group; under heavy congestion
video stops and audio lags by at most 500 ms. No protocol change, just knobs.

## Datagrams

Since moq-lite 05, a publisher can send a tiny single-frame group as a QUIC
datagram: unreliable, unordered, under about 1200 bytes, and never
retransmitted. It suits real-time audio and sensor data. There is no stream
fallback, so a datagram that doesn't fit isn't delivered that way.

## What moq-lite leaves out

Compared with moq-transport: no request IDs (a stream per request instead), no
push (subscribers always ask), fetches within a single group only, no
sub-groups (use a track per SVC layer), no gaps in object numbering, no per-object metadata
(encode it in the payload), no pausing (unsubscribe instead), and UTF-8 names
instead of byte arrays. When a peer negotiates moq-transport the implementation
still enforces this simpler model, faking or refusing the rest.

| Client | Relay | Works |
| --- | --- | --- |
| moq-lite | moq-lite | yes |
| moq-lite | moq-transport | yes |
| moq-transport | moq-lite | without moq-transport-only features |
| moq-transport | moq-transport | depends on the implementations |

## Protocol errors

Session close codes and stream reset codes use separate registries: session code 0
is a clean close, while stream code 0 is an internal error. Rust preserves received
codes as `moq_net::Error::Session(SessionError)` or `Error::Stream(StreamError)`;
JavaScript exposes `SessionError` and `StreamError`. Match the registry before
interpreting the number. Native bindings expose scope, code, kind, and a diagnostic
message; unknown and application codes retain their numeric value. Transport
failures without a protocol code remain separate.

## Local read limits

Group ranges name which groups a reader may deliver. In Rust,
`set_groups(2..=5)` includes group 5, while `set_groups(2..5)` excludes it.
TypeScript spells the endpoints explicitly:
`reader.setGroups({ start: { included: 2 }, end: { included: 5 } })`.

Changing these local limits preserves read progress. Raising the start skips
lower groups; lowering it never rewinds the reader. Raising or removing the
end cap makes unread buffered groups available again. These local limits do
not change upstream demand; subscription preferences control that separately.
The wire encoding is unchanged.
