Media over QUIC (MoQ) Implementation: Technical Achievement & Browser Reality

⚠️ The Uncomfortable Truth About MoQ

MoQ's success is 100% dependent on browser vendors. Without native browser support, MoQ remains a fascinating technical achievement that can't reach its full potential. Playing video in JavaScript using WebCodecs is not a viable long-term solution - it's a proof of concept desperately waiting for Google and Apple to get on board.

Table of Contents

πŸš€ Experience MoQ Live

See the 200-300ms latency for yourself (Chrome/Edge only)

1. What We Achieved

βœ… World's First Production MoQ for Open Source Media Server

  • 200-300ms latency - 10x faster than HLS, 5x faster than RTMP
  • Dual transport architecture - WebTransport for browsers, native QUIC for servers
  • Production tested - Running on real government traffic cameras
  • Fully open source - ~10,500 lines of Go and JavaScript
  • MediaMTX integration - Works with existing RTMP/RTSP/HLS streams

Latency Comparison

Protocol Typical Latency Best Case Worst Case vs MoQ
MoQ (Our Implementation) 200-300ms 180ms 400ms Baseline
WebRTC 500ms-2s 300ms 3s 2.5-10x slower
SRT 300-500ms 250ms 1s 1.5-2.5x slower
RTMP 1-3s 800ms 5s 5-15x slower
HLS 2-10s 1.5s 30s 10-50x slower

2. Live Demo & Code

πŸ“Ί Live Demo

URL: https://moq.wink.co/moq-player.html

Requirements: Chrome or Edge with WebTransport enabled

What You'll See: Real-time video with 200-300ms latency from camera to screen

πŸ’» Source Code

GitHub: https://github.com/winkmichael/mediamtx-moq

Language: Go (backend) + JavaScript (player)

License: Open Source

Integration: Full MediaMTX compatibility

3. Technical Implementation Details

The Path to 200ms Latency

Our MoQ implementation achieves ultra-low latency through several key innovations and hard-won discoveries. Here's the complete technical journey,, including all the dead ends and breakthroughs.

Architecture Overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Camera    │──────▢│   MediaMTX   │──────▢│  MoQ Protocol   │──────▢│ Browser β”‚
β”‚   (RTSP)    β”‚       β”‚    Server    β”‚       β”‚  WebTransport   β”‚       β”‚ Player  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚                         β”‚
                            β”‚                         β”œβ”€β”€ Port 4443 (WebTransport)
                            β”‚                         └── Port 4444 (Native QUIC)
                            β”‚
                      β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
                      β”‚ Existing  β”‚
                      β”‚ Protocols β”‚
                      β”‚ RTMP/HLS  β”‚
                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            

Key Components

1. MoQ Protocol Layer

2. Dual Transport System

Transport Port Use Case Implementation
WebTransport 4443 Browser playback HTTP/3 with WebTransport API
Native QUIC 4444 Server-to-server relay Raw QUIC streams

3. Critical Implementation Challenge: Stream Timing

The MediaMTX Integration Challenge

One of our biggest challenges was integrating with MediaMTX's path manager. The stream object exists immediately but the format description isn't populated until data arrives:

// Initial attempt - this crashes!
res, err := pm.AddReader(pathName, &PathAddReaderReq{
    Author: c,
})
stream := res.Stream
stream.AddReader(reader, nil, nil, callback) // PANIC: stream.Desc is nil!

// Working solution - wait for stream to be ready
for attempts := 0; attempts < 50; attempts++ {
    if res.Stream.Desc != nil {
        break
    }
    time.Sleep(100 * time.Millisecond)
}

if res.Stream.Desc == nil {
    return fmt.Errorf("stream not ready after 5 seconds")
}

// Now we can safely find formats
var videoFormatH264 *format.H264
videoMedia := res.Stream.Desc.FindFormat(&videoFormatH264)

if videoMedia == nil {
    return fmt.Errorf("H.264 format not found")
}

// Register reader with proper format
res.Stream.AddReader(reader, videoMedia, videoFormatH264, func(u unit.Unit) {
    // Process frames
})                

This timing issue took days to debug. Other protocols seem to get lucky with timing, but MoQ's immediate connection requirement exposed this race condition.

4. The WebTransport Discovery

Why We Have Two Ports

We spent weeks trying to connect browsers to our QUIC server on port 4444. Nothing worked. After diving deep into browser security models,, we discovered:

  • Browsers cannot make raw QUIC connections (security restriction)
  • WebTransport requires HTTP/3 upgrade negotiation
  • WebTransport uses different ALPN tokens than raw QUIC

Out of "confusion and frustration," we implemented both:

// WebTransport for browsers (port 4443)
wtServer := webtransport.Server{
    H3: http3.Server{
        Addr:      ":4443",
        TLSConfig: tlsConfig,
    },
}

// Native QUIC for server-to-server (port 4444)
quicListener, err := quic.ListenAddr(":4444", tlsConfig, &quic.Config{
    MaxIdleTimeout:        5 * time.Minute,
    MaxIncomingStreams:    256,
    MaxIncomingUniStreams: 256,
})
                

This "mistake" became a feature - we're the first implementation with dual transport support!

5. H.264 NAL Unit Processing

The H.264 Puzzle

MediaMTX provides Access Units containing multiple NAL units. WebCodecs expects complete frames in Annex B format. Here's what we learned:

func processH264AccessUnit(au *unit.H264, h264Format *format.H264) []byte {
    var frameData []byte
    hasIDR := false
    startCode := []byte{0x00, 0x00, 0x00, 0x01}
    
    // Check if this AU contains an IDR frame
    for _, nalu := range au.AU {
        nalType := nalu[0] & 0x1F
        if nalType == 5 { // IDR frame
            hasIDR = true
            break
        }
    }
    
    // Critical: Add SPS/PPS before every IDR frame
    if hasIDR && h264Format.SPS != nil && h264Format.PPS != nil {
        frameData = append(frameData, startCode...)
        frameData = append(frameData, h264Format.SPS...)
        frameData = append(frameData, startCode...)
        frameData = append(frameData, h264Format.PPS...)
    }
    
    // Process NAL units - only include VCL NALUs (types 1-5)
    for _, nalu := range au.AU {
        nalType := nalu[0] & 0x1F
        
        // Skip non-VCL NAL units (SEI, AUD, etc.)
        if nalType < 1 || nalType > 5 {
            continue
        }
        
        // Add VCL NAL unit with start code
        frameData = append(frameData, startCode...)
        frameData = append(frameData, nalu...)
    }
    
    return frameData
}
                

Key discoveries:

  • SEI NAL units (type 6) crash WebCodecs decoder
  • SPS/PPS must be included with every keyframe
  • Start codes are mandatory (no length prefixing)
  • Access Unit β‰  Frame (can contain multiple slices)

6. Browser Player Architecture

// Complete browser player implementation
class MoQPlayer {
    constructor() {
        this.webTransport = new WebTransport(url);
        this.videoDecoder = new VideoDecoder({
            output: frame => this.renderFrame(frame),
            error: e => console.error(e)
        });
        this.audioContext = new AudioContext();
        this.audioWorklet = new AudioWorkletNode();
    }

    async receiveStreams() {
        const streams = await this.webTransport.incomingBidirectionalStreams;
        for await (const stream of streams) {
            this.processStream(stream);
        }
    }

    async processStream(stream) {
        // Read MoQ messages
        const reader = stream.readable.getReader();
        while (true) {
            const {value, done} = await reader.read();
            if (done) break;
            
            // Parse MoQ message
            const message = this.parseMoQMessage(value);
            
            // Decode based on type
            if (message.type === 'video') {
                this.videoDecoder.decode(new EncodedVideoChunk(message.data));
            } else if (message.type === 'audio') {
                this.audioWorklet.port.postMessage(message.data);
            }
        }
    }
}
            

7. Audio Synchronization Solution

The Audio Challenge

Audio would play for 1 second then stop. The problem: Web Audio API scheduling conflicts when buffering too many chunks.

// WRONG - This kills audio after ~1 second
audioChunks.forEach(chunk => {
    playAudioChunk(chunk, audioContext.currentTime + offset);
    offset += duration;
});

// CORRECT - Sequential scheduling
class AudioPlayer {
    constructor() {
        this.audioContext = new AudioContext();
        this.audioWorklet = null;
        this.nextPlayTime = 0;
        this.audioQueue = [];
        this.setupWorklet();
    }
    
    async setupWorklet() {
        // Use Web Worker for audio decoding to prevent main thread blocking
        await this.audioContext.audioWorklet.addModule('audio-processor.js');
        this.audioWorklet = new AudioWorkletNode(this.audioContext, 'aac-processor');
        this.audioWorklet.connect(this.audioContext.destination);
    }
    
    scheduleAudio(audioData) {
        // Decode AAC to PCM
        const pcmData = this.decodeAAC(audioData);
        
        // Create buffer
        const audioBuffer = this.audioContext.createBuffer(
            2, // stereo
            pcmData.length / 2,
            44100 // sample rate
        );
        
        // Copy data to buffer
        audioBuffer.copyToChannel(pcmData.left, 0);
        audioBuffer.copyToChannel(pcmData.right, 1);
        
        // Schedule playback
        const source = this.audioContext.createBufferSource();
        source.buffer = audioBuffer;
        source.connect(this.audioContext.destination);
        
        // Play at the right time
        if (this.nextPlayTime < this.audioContext.currentTime) {
            this.nextPlayTime = this.audioContext.currentTime;
        }
        source.start(this.nextPlayTime);
        this.nextPlayTime += audioBuffer.duration;
    }
}
                

Key insight: Don't schedule everything at once. Process audio sequentially to maintain sync.

8. MoQ Message Protocol

Varint Encoding - The Heart of MoQ

MoQ uses variable-length integers for efficiency. Here's our implementation:

// Encoding varints for MoQ messages
function encodeVarInt(value) {
    if (value < 0x40) {
        return new Uint8Array([value]);
    } else if (value < 0x4000) {
        return new Uint8Array([
            0x40 | (value >> 8),
            value & 0xFF
        ]);
    } else if (value < 0x40000000) {
        return new Uint8Array([
            0x80 | (value >> 24),
            (value >> 16) & 0xFF,
            (value >> 8) & 0xFF,
            value & 0xFF
        ]);
    }
    // Up to 8 bytes for large values
}

// Decoding varints from stream
async function readVarInt(reader) {
    const { value: firstByte } = await reader.read();
    const prefix = firstByte & 0xC0;
    
    if (prefix === 0x00) {
        return firstByte;
    } else if (prefix === 0x40) {
        const { value: secondByte } = await reader.read();
        return ((firstByte & 0x3F) << 8) | secondByte;
    } else if (prefix === 0x80) {
        // Read 4 bytes total
        const bytes = await readBytes(reader, 3);
        return ((firstByte & 0x3F) << 24) | 
               (bytes[0] << 16) | 
               (bytes[1] << 8) | 
               bytes[2];
    }
    // Handle 8-byte case
}

// MoQ message structure
class MoQMessage {
    static SETUP = 0x40;
    static SETUP_OK = 0x41;
    static SUBSCRIBE = 0x03;
    static SUBSCRIBE_OK = 0x04;
    static OBJECT = 0x00;
    
    static encodeSetup(role, version) {
        const msg = [];
        msg.push(...encodeVarInt(MoQMessage.SETUP));
        msg.push(...encodeVarInt(version));
        msg.push(...encodeVarInt(role)); // 0x01 = subscriber
        return new Uint8Array(msg);
    }
    
    static encodeSubscribe(trackName, trackAlias) {
        const msg = [];
        msg.push(...encodeVarInt(MoQMessage.SUBSCRIBE));
        msg.push(...encodeVarInt(1)); // subscribe ID
        msg.push(...encodeVarInt(trackAlias));
        msg.push(...encodeVarInt(trackName.length));
        msg.push(...new TextEncoder().encode(trackName));
        return new Uint8Array(msg);
    }
}
                

9. Performance Optimizations

Optimization Impact Implementation
Frame Buffering Smooth 30fps playback Buffer 3-5 frames, render at fixed interval
Software Decoding More reliable than hardware hardwareAcceleration: 'prefer-software'
Audio Worklet No main thread blocking Decode AAC in Web Worker
Unidirectional Streams Lower overhead Each frame on new stream
Skip SEI NALUs Prevent decoder crashes Filter NAL types 1-5 only

4. The Browser Support Reality

🟒

Chrome/Edge

WebTransport: βœ… Supported

WebCodecs: βœ… Supported

Status: Works Today

🟑

Firefox

WebTransport: ⚠️ Behind Flag

WebCodecs: ⚠️ Partial

Status: Not Production Ready

πŸ”΄

Safari

WebTransport: ❌ Not Implemented

WebCodecs: ⚠️ Experimental

Status: No Timeline

πŸ”΄

iOS Safari

WebTransport: ❌ Not Implemented

WebCodecs: ❌ Not Available

Status: Completely Blocked

🚨 The Critical Problem

Without Safari support, MoQ cannot reach:

  • ~20% of desktop users (macOS Safari)
  • ~50% of mobile users (iOS requires Safari WebKit)
  • 100% of iOS app WebViews (all use Safari engine)

This means MoQ is currently unusable for consumer-facing applications.

5. Why WebCodecs Isn't The Answer

The WebCodecs Hack

Current MoQ implementations (including ours) use WebCodecs API to decode video in JavaScript. This is a clever workaround, but it's not a sustainable solution.

Problems with JavaScript Video Decoding

Issue Impact Why It Matters
CPU Usage 3-5x higher than native Drains battery, limits concurrent streams
Frame Drops Happens under load JavaScript thread blocking causes stuttering
Memory Usage 2-3x higher Frame buffers in JavaScript heap
Complexity 1000+ lines of code vs <video> tag simplicity
Audio Sync Requires Web Workers Complex timing coordination
Mobile Performance Often unusable Mobile CPUs can't handle it

What We Really Need

// This is what we have to do now (complex, inefficient):
const decoder = new VideoDecoder({...});
const transport = new WebTransport(url);
// ... 500 lines of complex stream handling ...

// This is what we SHOULD have (simple, efficient):
<video src="moq://stream.example.com/live" autoplay></video>
            

The Hard Truth

Playing video in JavaScript is like building a car engine with Lego blocks. It's an impressive technical achievement that proves the concept works, but you wouldn't want to drive it to work every day.

6. The Apple Problem

2019: QUIC Standardized

IETF finalizes QUIC (RFC 9000). Apple participates in working group.

2021: WebTransport Draft

W3C publishes WebTransport draft. Chrome implements. Apple silent.

2022: Chrome Ships WebTransport

Google enables WebTransport by default. Safari: "No position yet"

2023: Still Waiting

Multiple requests for Safari support. Apple: "Under consideration"

2024: Experimental WebCodecs

Safari adds partial WebCodecs behind flag. WebTransport: Still nothing.

2025: Current State

Safari has ZERO WebTransport support. No public timeline. No commitment.

Why Apple's Resistance Matters

iOS Lock-In Effect

On iOS, ALL browsers must use Safari's WebKit engine. This means:

  • Chrome on iOS can't use WebTransport (forced to use WebKit)
  • Firefox on iOS can't use WebTransport (forced to use WebKit)
  • Edge on iOS can't use WebTransport (forced to use WebKit)
  • Every iOS app with a WebView can't use WebTransport

Apple's decision blocks MoQ on the entire iOS ecosystem - roughly 1 billion devices.

Theories on Apple's Resistance

  1. HLS Protection: Apple invented HLS and has significant investment in it
  2. Control: WebTransport reduces Apple's control over media delivery
  3. Resources: Safari team is smaller than Chrome team
  4. Strategy: Wait and see if MoQ actually succeeds first

7. What Needs to Happen

βœ… For MoQ to Succeed

  1. Native Browser Support
    • Browsers must handle MoQ natively like they handle HLS
    • <video src="moq://..."> should just work
    • No JavaScript decoding required
  2. Apple Must Implement WebTransport
    • Safari needs full WebTransport support
    • iOS WebKit needs the same
    • Without Apple, MoQ is DOA for consumer apps
  3. CDN Support
    • Cloudflare has started (good!)
    • Need Fastly, Akamai, CloudFront
    • Edge infrastructure must support QUIC
  4. Encoder/Camera Support
    • Cameras need native MoQ output
    • OBS should support MoQ ingest
    • Hardware encoders need firmware updates

The Chicken and Egg Problem

The Dilemma:

  • Apple won't implement until MoQ is proven successful
  • MoQ can't be successful without Apple support
  • Developers won't adopt without browser support
  • Browsers won't prioritize without developer demand

Someone has to move first. Our bet: Google will force the issue by making YouTube Live use MoQ.

8. Production Readiness Assessment

Use Case Ready? Why / Why Not
B2B Surveillance (Chrome/Edge only) βœ… YES Controlled environment, can mandate browser
Internal Corporate Streaming βœ… YES IT can standardize on Chrome/Edge
Server-to-Server Relay βœ… YES Native QUIC works great
Government Public Portals ⚠️ MAYBE Can't exclude Safari users
Consumer Live Streaming ❌ NO Must support all browsers
Mobile Apps ❌ NO iOS WebView doesn't support
Smart TV Apps ❌ NO TV browsers are years behind

9. Current Alternatives Comparison

For Ultra-Low Latency Today

Solution Latency Browser Support Pros Cons
WebRTC 500ms-2s All browsers Universal support, P2P capable Complex, NAT issues, not for broadcast
LL-HLS 2-3s All browsers Apple standard, CDN friendly Still seconds of delay
WebTransport + MSE 1-2s Chrome only Better than HLS Complex, Chrome only
MoQ (Today) 200-300ms Chrome/Edge only Lowest latency, future-proof No Safari, needs JavaScript decoder

πŸ’‘ Recommendation

For B2B/Enterprise: Use MoQ if you can control browser choice

For B2C/Consumer: Stick with WebRTC or LL-HLS until Safari supports WebTransport

For Future-Proofing: Build MoQ support now, but keep fallbacks

10. Known Limitations & Issues

Current Implementation Gaps

  • No Adaptive Bitrate - Single quality stream only
  • Fixed GOP Size - Assumes 15-frame groups
  • No Frame Dropping - Buffer grows indefinitely under poor network
  • Limited Codec Support - H.264/AAC only (no VP9, AV1, Opus)
  • No Safari Support - Apple hasn't implemented WebTransport

Latency Breakdown (200-300ms total)

Component Latency Notes
Encoding 50-100ms FFmpeg x264 encoding
Network 20-50ms QUIC transport overhead
Buffering 50-100ms Frame buffer in browser
Decoding 30-50ms WebCodecs processing
Rendering 33ms One frame at 30fps

Performance Metrics

CPU Usage

MediaMTX Process:
β”œβ”€β”€ RTMP Ingestion: ~5%
β”œβ”€β”€ H.264 Processing: ~8%
β”œβ”€β”€ MoQ Encoding: ~2%
└── Network I/O: ~3%
Total: ~18%

Browser:
β”œβ”€β”€ WebTransport: ~10%
β”œβ”€β”€ Video Decode: ~15%
β”œβ”€β”€ Audio Decode: ~5%
β”œβ”€β”€ Rendering: ~10%
└── JavaScript: ~5%
Total: ~45%
                

Resource Requirements

  • Server Memory: ~70MB for MediaMTX + MoQ
  • Browser Memory: ~150MB (includes decoded frame buffer)
  • Network Bandwidth: ~630kbps per viewer (500kbps video + 96kbps audio + overhead)

Not Production Ready For

Use Case Issue Workaround
High packet loss networks (>5%) No advanced FEC Use SRT instead
Mobile devices JavaScript decoding too heavy Wait for native support
Large scale broadcasting No CDN integration yet Use HLS for scale
DRM protected content No encryption support Not applicable
Live captions/subtitles No metadata tracks Overlay manually

11. Debugging & Testing

Enable Debug Logging

# mediamtx.yml
logLevel: debug
logDestinations: [stdout]

# Enable MoQ specific logs
servers:
  moq:
    enable: yes
    debug: true
                

Chrome WebTransport Setup

# Enable experimental features
chrome://flags/#enable-experimental-web-platform-features

# Check WebTransport status
chrome://webrtc-internals/
                

Network Monitoring

# Monitor QUIC traffic
sudo tcpdump -i any -nn port 4443 or port 4444

# Test with moq-rs tools
moq-relay --listen 0.0.0.0:4444
moq-pub --url https://localhost:4444/test video.mp4
                

12. Acknowledgments & Credits

Standing on the Shoulders of Giants

This implementation wouldn't exist without the foundational work of many talented engineers and organizations:

Core Inspirations

  • MediaMTX by Alessandro Ros (@aler9) - The brilliant media server that made this integration possible. The path manager architecture is genius.
  • moq-rs by @kixelated - Reference implementation and testing tools that validated our approach
  • Meta's MoQ Team - Their WebCodecs integration examples solved critical decoder issues
  • Cloudflare - Pushing MoQ forward with CDN infrastructure and excellent documentation
  • quic-go Team - Rock-solid QUIC implementation for Go
  • JSMpeg - Inspiration for JavaScript video decoding approach (we feel your pain!)

Technical References

  • IETF MoQ Working Group for protocol standardization
  • W3C WebTransport specification authors
  • WebCodecs API designers at Google
  • FFmpeg team for H.264/AAC processing

Special Thanks

To everyone who's tried to achieve sub-second latency and shared their failures and successes. The streaming community's openness made this possible.

13. Conclusion: The MoQ Paradox

What We've Proven

  • βœ… MoQ works - we achieved 200-300ms latency
  • βœ… The protocol is solid and production-ready
  • βœ… It's 10x better than current solutions
  • βœ… The future of streaming is clearly QUIC-based

What We're Waiting For

  • ❌ Safari WebTransport support (no timeline)
  • ❌ Native browser MoQ handling (years away)
  • ❌ Broad CDN adoption (just starting)
  • ❌ Industry consensus (still debating)

The Bottom Line

MoQ is technically superior but practically limited.

We've built a Ferrari engine, but we're waiting for the roads to be paved. The technology is ready, but the ecosystem isn't. Until Apple implements WebTransport and browsers handle MoQ natively, it remains a proof of concept for most use cases.

Our implementation proves MoQ works. Now we need Google to push harder, Apple to stop resisting, and CDNs to build infrastructure. The revolution in sub-second streaming is technically here - we're just waiting for the industry to catch up.

Want to Try It Anyway?

If you're in a controlled environment where you can mandate Chrome/Edge, MoQ is ready today.