TL;DR
- Bitmovin’s Player Web X had MOQ playback built on @moq/lite, an actively maintained library but a stripped down version of the IETF standard specs. This hackathon swapped it for Playa, Red5 Pro’s actively maintained IETF draft reference implementation.
- Several compatibility issues were found and fixed during the integration, ranging from a Playa state machine inconsistency to WebTransport sub-protocol negotiation that several relays require at the connection stage.
- Backward seek was implemented and works on the client side. Relays we tested with this do not retain past groups today, so the feature is ready but waiting on relay infrastructure to catch up.
MOQT (Media over QUIC Transport) is still an evolving standard, which means the libraries and implementations built on it are a moving target. For Bitmovin as a member of the OpenMOQ consortium, staying current is not optional. A Player built on a library that does not implement the full IETF drafts will fall behind as the protocol moves forward. That was the situation with @moq/lite, the library we had been using for MOQ playback in Bitmovin’s Player Web X. It got us moving quickly when we first added MOQ support, but it had not kept pace with the evolving drafts.
This hackathon I replaced it with Playa, Red5 Pro’s reference MOQT implementation, with one constraint. Nothing above the transport layer in the Player could change.
Why @moq/lite had to go
@moq/lite got us to MOQ playback quickly and served its purpose well at the time. But as the IETF MOQ working group moved from draft 16 toward draft 18, we needed a lib that is based on the full MOQT spec rather than the stripped down moq-lite. Staying current with the standard is part of what Bitmovin’s consortium membership means in practice, so we needed a library that was actively tracking the drafts.
Playa, the reference MOQT implementation from Red5 Pro, was the right replacement. It was already draft-16 compliant at the time of the hackathon, actively implementing draft 18, and supports multiple draft versions (14 and 16) with rich event hooks. Backed by an active contributor community, moving to it meant the Player’s Web X MOQ stack would track the standard going forward rather than drift from it. The goal was to future-proof our MOQ stack without rewriting the Bitmovin pipeline above the transport layer.
The API mismatch: promise based versus callback based
@moq/lite is Promise-based, Playa is callback-based. The two libraries work in opposite directions in Player Web X. Below is what that looks like in practice.
@moq/lite (Promise-based)
- The Player calls the library when it needs frames, using await track.readFrame() and await track.nextGroup()
- Subscribing is synchronous, sync subscribe(name, priority) returns immediately
- No Broadcast abstraction, namespace passed on every subscribeTrack call
Playa (callback-based)
- The library calls your code when data arrives, via sub.onObject = (obj) => { … }
- Subscribing is async, subscribeTrack resolves on SUBSCRIBE_OK and returns raw MoqtObjects
- Broadcast class wraps connection and namespace together
The entire Player pipeline above the transport layer is built around a Promise-based interface. A direct swap would have required rewriting everything above it, so instead I built a translation layer between the two paradigms.
Building the adapter layer
The adapter is five files: MoqTransport, MoqBroadcast, MoqTrack, MoqGroup, and ObjectQueue. Together they translate Playa’ s callback model into the promise-based interface the player expects. When Playa’s onObject callback fires, the adapter places the incoming object into a bounded async queue. The ObjectQueue also handles group reassembly, collecting objects by groupId and detecting group boundaries via END_OF_GROUP markers before surfacing them to the player. The player then reads from that queue using its existing readFrame and nextGroup calls, completely unaware that anything changed underneath.
Player Web X is built on an internal reactive framework based on Structured Concurrency, and several of its patterns made the adapter unusually clean to write. Effects like the EventListenerEffect and StateEffect allow you to set up observable state objects and to add event listeners, all in a structured concurrency safe way. The player codebase stayed minimal and explicit, and everything above it remained completely untouched.
Compatibility issues fixed along the way
Swapping a low-level transport library exposes edge cases that are invisible under normal conditions. The following came up during the hackathon, and all were resolved:
- A state machine inconsistency in Playa caused it to assert ESTABLISHED state after a TERMINATED transition when PUBLISH_DONE arrived. Patched directly in Playa.
- A resubscribe race condition left a GroupConsumer awaiting nextGroup past an abort signal. Fixed by wiring the abort signal to close MoqTrack synchronously instead of waiting for the async path to unblock.
- WebTransport sub-protocol negotiation is mandatory. Several relays reject connections at the CONNECT stage without it. Added explicit negotiation sending [‘moqt-16’] or [‘moqt-14’, ‘moq-00’] depending on the relay.
- MAX_REQUEST_ID defaults to 0 in Playa, which most relays treat as invalid and reject immediately. Set to 1000.
- Empty namespaces hang indefinitely without a timeout. Added a 10-second catalog read timeout so the player fails fast and clearly.
- The load() method was synchronous and needed to return a Promise resolving on catalog arrival or rejecting on error, to fit the player’s async initialization model.
- Strict request-ID +2 sequencing in Playa broke with relays that send unsolicited PUBLISH_NAMESPACE messages and skip IDs in the process. Relaxed the check in Playa to accept non-sequential IDs.
One relay we tested ran a hybrid protocol version straddling two draft formats, which would have required a deeper Playa rewrite than was practical during a hackathon, so we documented it for later.
Working on backward seek
Beyond the core library swap, I wanted to test the practical limits of backward seeking in MOQ, specifically rewinding a live stream by a given number of seconds. The IETF working group is still refining how this should work at the protocol level, but I wanted to understand the constraints from first principles.
The implementation exposes a seek(seconds) method that accepts a signed delta from the current playhead. Each track maintains a currentGroupId that updates as objects arrive. Calling seek(-N) computes a target group as currentGroup minus N, stores it in a seekTarget atom, cycles the track through Disable and back to Enable via setTimeout(0) to flush state, then resubscribes with an AbsoluteStart(target) filter pointing at the target group. A lastSeek snapshot and a getLastSeek() diagnostic API expose the last seek state for UI inspection. The code path is correct end to end, and the demo confirmed it works as implemented.
Relay infrastructure is the current limiting factor. Every public relay we tested accepts the AbsoluteStart subscription and returns SUBSCRIBE_OK, then delivers from the live edge regardless. MOQ is fundamentally a live pub/sub protocol, and backward seeking requires a relay that retains past groups in storage. No production relay does that today. The client side is ready, and relay support for this is something the ecosystem is actively working toward.
What shipped on the branch
The branch includes the full five-file adapter over Playa, two patches against Playa itself (the state machine bug fix and relaxed request-ID sequencing), WebTransport sub-protocol negotiation for both moqt-16 and moqt-14/moq-00, async load() with catalog timeout and error propagation, the seek(seconds) and getLastSeek() APIs, and a hackathon demo page covering four relays with live latency display, seek diagnostics, and Bitmovin branding.
The shift from @moq/lite to Playa puts Bitmovin’s Player Web X on a much stronger foundation for MOQ long term. Protocol changes in future drafts can be absorbed at the transport layer without touching the decoder or renderer stack above it. The compatibility work we did reflects something worth knowing about the current MOQ ecosystem: the protocol is close to production-ready, but the gaps between implementations still require careful handling at the integration layer. We are now basing PWX on the reference implementation published by the OpenMOQ initiative, proving that it can drive real world use cases in commercial products.