MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

WebTransport: Low-Latency Communication for Games and Media

Implement WebTransport: unreliable datagrams, bidirectional streams, and use cases.

WebTransportReal-TimeGamingFrontend

By MinhVo

Introduction

Cloud gaming, real-time multiplayer games, and live media streaming share a common requirement: data must arrive within milliseconds or it becomes worthless. A player's position update that arrives 200ms late is worse than one that never arrives at all, because the client renders a stale position and the player sees a jarring correction. WebSocket's TCP foundation makes this problem unavoidable β€” every lost packet blocks all subsequent data until the retransmission arrives.

WebTransport solves this by providing unreliable datagrams alongside reliable streams, all running over QUIC (UDP). For games, this means position updates can be sent as datagrams: they arrive fast or not at all, but they never block subsequent updates. Critical events like kills, chat messages, and inventory changes use reliable streams to guarantee delivery. The combination gives game developers the same flexibility that native game engines have enjoyed for decades, now available in the browser.

This guide focuses on practical implementation for game developers and media engineers. We will build a real-time multiplayer game client that uses datagrams for player positions and reliable streams for game events, a media streaming pipeline that delivers video frames as datagrams with minimal latency, and an input synchronization system for fighting games and racing games where frame-perfect timing matters.

Gaming and media

Why QUIC Changes Everything

WebTransport is built on QUIC, the protocol that also powers HTTP/3. Understanding QUIC's design helps explain why WebTransport performs so well for real-time applications.

UDP Foundation Without the Drawbacks

Raw UDP gives you speed but no reliability, ordering, or congestion control. QUIC adds these features at the transport layer while preserving UDP's key advantage: no head-of-line blocking between independent streams. In TCP, if packet 5 is lost, packets 6-100 are all blocked until packet 5 is retransmitted. In QUIC, each stream is independently reliableβ€”a lost packet on stream A doesn't block stream B.

For WebTransport, this means you can have multiple reliable bidirectional streams running simultaneously, and a loss on one stream won't affect the others. Combined with the unreliable datagram channel, you get the best of both worlds: ordered, reliable delivery where you need it (chat messages, game events) and fire-and-forget speed where you don't (position updates, voice data).

Connection Migration

QUIC connections survive network changes. When a player switches from WiFi to cellular mid-game, the connection continues seamlessly because QUIC connections are identified by a connection ID, not by the IP address and port tuple that TCP uses. This is a massive improvement for mobile gaming, where network transitions are frequent.

0-RTT Connection Establishment

QUIC supports 0-RTT resumption for returning clients. The first connection requires a TLS 1.3 handshake (1 RTT), but subsequent connections can send data immediately (0 RTT). For games where players reconnect after brief disconnects, this means near-instant reconnection instead of the multi-second delay imposed by TCP + TLS.

// Connection with 0-RTT support
async function connectToGame(url) {
  const transport = new WebTransport(url, {
    // Enable 0-RTT for returning connections
    serverCertificateHashes: [{
      algorithm: 'sha-256',
      value: await getServerCertHash(url),
    }],
  });
 
  await transport.ready;
  console.log(`Connected in ${transport.rtt}ms RTT`);
  return transport;
}

Browser Support and Fallbacks

WebTransport is supported in Chrome 97+, Edge 97+, and Firefox 114+. Safari support is in development. For browsers that don't support WebTransport, you need a fallback strategy:

class RealTimeTransport {
  constructor(url) {
    this.url = url;
  }
 
  async connect() {
    if (typeof WebTransport !== 'undefined') {
      this.transport = new WebTransport(this.url);
      await this.transport.ready;
      this.mode = 'webtransport';
    } else if (typeof WebSocket !== 'undefined') {
      this.transport = new WebSocket(this.url.replace('https:', 'wss:'));
      await new Promise((resolve, reject) => {
        this.transport.onopen = resolve;
        this.transport.onerror = reject;
      });
      this.mode = 'websocket';
    }
 
    return this;
  }
 
  async sendDatagram(data) {
    if (this.mode === 'webtransport') {
      const writer = this.transport.datagrams.writable.getWriter();
      await writer.write(data);
      writer.releaseLock();
    } else {
      // WebSocket fallback: prefix unreliable data with flag byte
      const packet = new Uint8Array(data.length + 1);
      packet[0] = 0x00; // unreliable flag
      packet.set(data, 1);
      this.transport.send(packet);
    }
  }
 
  async sendReliable(data) {
    if (this.mode === 'webtransport') {
      const stream = await this.transport.createBidirectionalStream();
      const writer = stream.writable.getWriter();
      await writer.write(data);
      await writer.close();
    } else {
      const packet = new Uint8Array(data.length + 1);
      packet[0] = 0x01; // reliable flag
      packet.set(data, 1);
      this.transport.send(packet);
    }
  }
}

Understanding Latency in Real-Time Applications

The Latency Budget

For a cloud gaming application, the latency budget looks like this:

StageTargetMaximum
Input capture1ms2ms
Network (client β†’ server)5ms15ms
Server processing5ms10ms
Network (server β†’ client)5ms15ms
Render + display8ms16ms
Total24ms58ms

WebSocket over TCP adds variable latency due to head-of-line blocking. In the worst case, a single lost packet adds 100-200ms of latency (TCP retransmission timeout), blowing the entire budget.

WebTransport datagrams have no retransmission. If a packet is lost, the next one arrives on schedule. The client uses the most recent data, which is fresher than waiting for a retransmission of stale data.

Measuring Network Quality

class NetworkQualityMonitor {
  constructor() {
    this.rttHistory = [];
    this.lossHistory = [];
    this.jitterHistory = [];
  }
 
  // Measure round-trip time using datagram ping/pong
  async measureRTT(transport) {
    const writer = transport.datagrams.writable.getWriter();
    const reader = transport.datagrams.readable.getReader();
    const encoder = new TextEncoder();
    const decoder = new TextDecoder();
 
    const sent = performance.now();
    await writer.write(encoder.encode(JSON.stringify({ type: 'ping', ts: sent })));
 
    // Read pong
    while (true) {
      const { value } = await reader.read();
      const msg = JSON.parse(decoder.decode(value));
      if (msg.type === 'pong' && msg.ts === sent) {
        const rtt = performance.now() - sent;
        this.rttHistory.push(rtt);
        if (this.rttHistory.length > 100) this.rttHistory.shift();
        return rtt;
      }
    }
  }
 
  getStats() {
    const sorted = [...this.rttHistory].sort((a, b) => a - b);
    return {
      rttP50: sorted[Math.floor(sorted.length * 0.5)] || 0,
      rttP95: sorted[Math.floor(sorted.length * 0.95)] || 0,
      rttP99: sorted[Math.floor(sorted.length * 0.99)] || 0,
      jitter: this.calculateJitter(),
    };
  }
 
  calculateJitter() {
    if (this.rttHistory.length < 2) return 0;
    let totalDiff = 0;
    for (let i = 1; i < this.rttHistory.length; i++) {
      totalDiff += Math.abs(this.rttHistory[i] - this.rttHistory[i - 1]);
    }
    return totalDiff / (this.rttHistory.length - 1);
  }
}

Latency diagram

Architecture and Design Patterns

Game Client with Input Prediction

For competitive games, input prediction reduces perceived latency. The client predicts the result of its own input immediately, then reconciles with the server state:

class GameClient {
  constructor(serverUrl) {
    this.serverUrl = serverUrl;
    this.localState = { x: 0, y: 0, health: 100 };
    this.inputSequence = 0;
    this.pendingInputs = [];
    this.serverState = null;
  }
 
  async connect() {
    this.transport = new WebTransport(this.serverUrl);
    await this.transport.ready;
 
    // Datagram writer for inputs (high frequency, loss-tolerant)
    this.inputWriter = this.transport.datagrams.writable.getWriter();
 
    // Reliable stream for critical events
    this.eventStream = await this.transport.createBidirectionalStream();
 
    // Start reading server state
    this.readServerState();
    this.readEvents();
 
    // Start game loop
    this.gameLoop();
  }
 
  // Called at 60fps
  gameLoop() {
    const input = this.captureInput();
    if (input) {
      this.applyInput(input);
      this.sendInput(input);
    }
    this.render();
    requestAnimationFrame(() => this.gameLoop());
  }
 
  captureInput() {
    const keys = this.keysPressed;
    if (!keys.up && !keys.down && !keys.left && !keys.right) return null;
 
    return {
      seq: this.inputSequence++,
      ts: performance.now(),
      up: keys.up,
      down: keys.down,
      left: keys.left,
      right: keys.right,
    };
  }
 
  // Client-side prediction: apply input immediately
  applyInput(input) {
    const speed = 5;
    if (input.up) this.localState.y -= speed;
    if (input.down) this.localState.y += speed;
    if (input.left) this.localState.x -= speed;
    if (input.right) this.localState.x += speed;
 
    // Save for reconciliation
    this.pendingInputs.push(input);
  }
 
  // Send input as datagram (binary, minimal overhead)
  async sendInput(input) {
    const buffer = new ArrayBuffer(16);
    const view = new DataView(buffer);
    view.setUint32(0, input.seq);
    view.setFloat32(4, input.up ? -1 : input.down ? 1 : 0);
    view.setFloat32(8, input.left ? -1 : input.right ? 1 : 0);
    view.setFloat32(12, input.ts);
    await this.inputWriter.write(new Uint8Array(buffer));
  }
 
  // Read authoritative server state via datagrams
  async readServerState() {
    const reader = this.transport.datagrams.readable.getReader();
 
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
 
      const view = new DataView(value.buffer);
      const lastProcessedInput = view.getUint32(0);
      const serverX = view.getFloat32(4);
      const serverY = view.getFloat32(8);
 
      // Reconciliation: discard inputs the server has processed
      this.pendingInputs = this.pendingInputs.filter(
        i => i.seq > lastProcessedInput
      );
 
      // Update server state
      this.serverState = { x: serverX, y: serverY };
 
      // Re-apply pending inputs on top of server state
      this.localState.x = serverX;
      this.localState.y = serverY;
      for (const input of this.pendingInputs) {
        this.applyInput(input);
      }
    }
  }
 
  // Critical events via reliable stream
  async readEvents() {
    const reader = this.eventStream.readable.getReader();
    const decoder = new TextDecoder();
 
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
 
      const events = decoder.decode(value).split('\n').filter(Boolean);
      for (const event of events) {
        this.handleEvent(JSON.parse(event));
      }
    }
  }
 
  handleEvent(event) {
    switch (event.type) {
      case 'player_died':
        this.showDeathScreen(event);
        break;
      case 'round_end':
        this.showRoundResults(event);
        break;
      case 'chat':
        this.showChatMessage(event);
        break;
    }
  }
 
  render() {
    // Draw all players at their predicted positions
    this.drawPlayer(this.localState, 'local');
    for (const [id, state] of this.remotePlayers) {
      this.drawPlayer(state, 'remote');
    }
  }
}

Server-Side Game Loop

// Node.js game server using @perbytes/webtransport
class GameServer {
  constructor() {
    this.players = new Map();
    this.tickRate = 60;
    this.tickInterval = 1000 / this.tickRate;
  }
 
  async start() {
    this.server = new Http3Server({
      host: '0.0.0.0',
      port: 4433,
      cert: './cert.pem',
      privKey: './key.pem',
    });
 
    this.server.on('session', (session) => this.handleSession(session));
    this.server.startServer();
 
    // Game loop at 60fps
    setInterval(() => this.tick(), this.tickInterval);
  }
 
  async handleSession(session) {
    await session.ready;
    const playerId = this.generateId();
 
    this.players.set(playerId, {
      id: playerId,
      session,
      x: 0,
      y: 0,
      lastInputSeq: 0,
    });
 
    // Read player inputs via datagrams
    const reader = session.datagrams.readable.getReader();
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      this.processInput(playerId, value);
    }
 
    this.players.delete(playerId);
  }
 
  processInput(playerId, data) {
    const view = new DataView(data.buffer);
    const seq = view.getUint32(0);
    const dy = view.getFloat32(4);
    const dx = view.getFloat32(8);
 
    const player = this.players.get(playerId);
    if (!player) return;
 
    const speed = 5;
    player.x += dx * speed;
    player.y += dy * speed;
    player.lastInputSeq = seq;
  }
 
  // Send authoritative state to all players at 60fps
  async tick() {
    const promises = [];
    for (const [id, player] of this.players) {
      promises.push(this.sendState(player));
    }
    await Promise.allSettled(promises);
  }
 
  async sendState(player) {
    const buffer = new ArrayBuffer(12);
    const view = new DataView(buffer);
    view.setUint32(0, player.lastInputSeq);
    view.setFloat32(4, player.x);
    view.setFloat32(8, player.y);
 
    try {
      const writer = player.session.datagrams.writable.getWriter();
      await writer.write(new Uint8Array(buffer));
      writer.releaseLock();
    } catch (e) {
      // Player disconnected
    }
  }
}

Multiplayer game architecture

Media Streaming with WebTransport

Low-Latency Video Pipeline

class MediaStreamReceiver {
  constructor() {
    this.frameBuffer = [];
    this.lastRenderedFrame = -1;
 
    this.decoder = new VideoDecoder({
      output: (frame) => this.onDecodedFrame(frame),
      error: (e) => console.error('Decoder error:', e),
    });
  }
 
  async connect(url) {
    this.transport = new WebTransport(url);
    await this.transport.ready;
 
    // Reliable stream for quality control
    this.controlStream = await this.transport.createBidirectionalStream();
 
    // Start reading video frames as datagrams
    this.readFrames();
  }
 
  async readFrames() {
    const reader = this.transport.datagrams.readable.getReader();
 
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
 
      // Parse frame header
      const header = new DataView(value.buffer, 0, 12);
      const frameNum = header.getUint32(0);
      const timestamp = header.getUint32(4);
      const isKeyFrame = header.getUint8(8);
      const codec = header.getUint8(9);
 
      // Skip if we already have a newer frame
      if (frameNum <= this.lastRenderedFrame) continue;
 
      const frameData = value.slice(12);
      const chunk = new EncodedVideoChunk({
        type: isKeyFrame ? 'key' : 'delta',
        timestamp: timestamp * 1000,
        data: frameData,
      });
 
      this.decoder.decode(chunk);
      this.lastRenderedFrame = frameNum;
    }
  }
 
  onDecodedFrame(frame) {
    const canvas = document.getElementById('stream-canvas');
    const ctx = canvas.getContext('2d');
    ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
    frame.close();
  }
 
  async requestKeyFrame() {
    const writer = this.controlStream.writable.getWriter();
    await writer.write(new TextEncoder().encode(JSON.stringify({ type: 'keyframe' }) + '\n'));
    writer.releaseLock();
  }
 
  async setQuality(level) {
    const writer = this.controlStream.writable.getWriter();
    await writer.write(new TextEncoder().encode(JSON.stringify({
      type: 'quality',
      level, // 'low', 'medium', 'high'
    }) + '\n'));
    writer.releaseLock();
  }
}

Audio Streaming

class AudioStreamReceiver {
  constructor() {
    this.audioContext = new AudioContext({ sampleRate: 48000 });
    this.nextPlayTime = 0;
  }
 
  async connect(url) {
    this.transport = new WebTransport(url);
    await this.transport.ready;
    this.readAudioFrames();
  }
 
  async readAudioFrames() {
    const reader = this.transport.datagrams.readable.getReader();
 
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
 
      // Parse audio frame header (4 bytes)
      const header = new DataView(value.buffer, 0, 4);
      const sampleRate = header.getUint16(0);
      const channels = header.getUint8(2);
 
      const audioData = value.slice(4);
      const samples = new Float32Array(audioData.buffer);
 
      const buffer = this.audioContext.createBuffer(
        channels,
        samples.length / channels,
        sampleRate
      );
 
      for (let ch = 0; ch < channels; ch++) {
        const channelData = buffer.getChannelData(ch);
        for (let i = 0; i < samples.length / channels; i++) {
          channelData[i] = samples[i * channels + ch];
        }
      }
 
      const source = this.audioContext.createBufferSource();
      source.buffer = buffer;
      source.connect(this.audioContext.destination);
 
      // Schedule playback to avoid gaps
      const now = this.audioContext.currentTime;
      const startTime = Math.max(this.nextPlayTime, now);
      source.start(startTime);
      this.nextPlayTime = startTime + buffer.duration;
    }
  }
}

Best Practices for Games and Media

  1. Use binary formats exclusively: At 60fps, every millisecond of serialization matters. Use ArrayBuffer with DataView for structured data. Avoid JSON for position updates and input data.

  2. Implement client-side prediction: Apply inputs locally before the server confirms them. This makes the game feel responsive even with 50ms of network latency.

  3. Reconcile with server state: When the server sends authoritative state, discard acknowledged inputs and replay unacknowledged ones on top of the server state.

  4. Interpolate remote players: Do not render remote players at their latest received position. Interpolate between the two most recent positions for smooth movement.

  5. Use separate channels for different data types: Position updates go in datagrams, game events go in reliable streams. Never mix unreliable and reliable data on the same channel.

  6. Monitor network quality: Track RTT, packet loss, and jitter. Adapt your send rate and quality based on network conditions. If jitter is high, increase the interpolation buffer.

  7. Implement adaptive quality for media: If the network cannot keep up, reduce video resolution or frame rate dynamically. Send quality change requests via the reliable control stream.

Common Pitfalls

PitfallImpactSolution
Using reliable streams for positionsStutter from head-of-line blockingUse datagrams for positions
No client-side predictionInput feels laggyApply inputs locally first
No interpolation for remote playersJerky movementInterpolate between received states
JSON serialization at 60fpsCPU bottleneckUse binary formats
No adaptive qualityBuffering under poor networkMonitor and adapt quality
Buffering datagramsLatency increaseProcess datagrams immediately
Missing keyframe requestsVideo freezes after packet lossRequest keyframes on decode errors

Connection Management and Error Handling

WebTransport connections can fail for various reasons: network drops, server crashes, or graceful shutdowns. Robust applications need to handle all of these cases:

class ManagedTransport {
  constructor(url, options = {}) {
    this.url = url;
    this.reconnectDelay = options.reconnectDelay || 1000;
    this.maxReconnectDelay = options.maxReconnectDelay || 30000;
    this.listeners = new Map();
    this.state = 'disconnected';
  }
 
  async connect() {
    this.state = 'connecting';
    try {
      this.transport = new WebTransport(this.url);
 
      // Monitor connection state
      this.transport.closed.then((info) => {
        console.log('Connection closed:', info.reason);
        this.state = 'disconnected';
        this.emit('disconnected', info);
        this.scheduleReconnect();
      }).catch((error) => {
        console.error('Connection error:', error);
        this.state = 'disconnected';
        this.emit('error', error);
        this.scheduleReconnect();
      });
 
      await this.transport.ready;
      this.state = 'connected';
      this.reconnectAttempt = 0;
      this.emit('connected');
    } catch (error) {
      this.state = 'disconnected';
      this.emit('error', error);
      this.scheduleReconnect();
    }
  }
 
  scheduleReconnect() {
    if (this.state === 'reconnecting') return;
    this.state = 'reconnecting';
 
    const delay = Math.min(
      this.reconnectDelay * Math.pow(2, this.reconnectAttempt || 0),
      this.maxReconnectDelay
    );
    this.reconnectAttempt = (this.reconnectAttempt || 0) + 1;
 
    console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
    setTimeout(() => this.connect(), delay);
  }
 
  async send(data, reliable = false) {
    if (this.state !== 'connected') {
      console.warn('Cannot send: not connected');
      return false;
    }
 
    try {
      if (reliable) {
        const stream = await this.transport.createBidirectionalStream();
        const writer = stream.writable.getWriter();
        await writer.write(data);
        await writer.close();
      } else {
        const writer = this.transport.datagrams.writable.getWriter();
        await writer.write(data);
        writer.releaseLock();
      }
      return true;
    } catch (error) {
      console.error('Send error:', error);
      return false;
    }
  }
 
  on(event, callback) {
    if (!this.listeners.has(event)) this.listeners.set(event, []);
    this.listeners.get(event).push(callback);
  }
 
  emit(event, data) {
    const callbacks = this.listeners.get(event) || [];
    for (const cb of callbacks) cb(data);
  }
}

Security Considerations

WebTransport requires HTTPS in production. The connection is always encrypted via TLS 1.3, which is enforced at the protocol level. There are several security considerations for game and media applications:

Authentication: Authenticate players during the initial connection handshake. Use the reliable stream to exchange tokens before allowing datagram access:

async function authenticateSession(transport, token) {
  const stream = await transport.createBidirectionalStream();
  const writer = stream.writable.getWriter();
  const reader = stream.readable.getReader();
 
  // Send auth token
  await writer.write(new TextEncoder().encode(JSON.stringify({
    type: 'auth',
    token: token,
  })));
 
  // Read auth response
  const { value } = await reader.read();
  const response = JSON.parse(new TextDecoder().decode(value));
 
  if (!response.success) {
    transport.close();
    throw new Error('Authentication failed');
  }
 
  return response.playerId;
}

Rate Limiting: Implement server-side rate limiting on datagram processing to prevent denial-of-service attacks. A malicious client could flood the server with datagrams at high frequency:

class DatagramRateLimiter {
  constructor(maxPerSecond = 120) {
    this.maxPerSecond = maxPerSecond;
    this.counters = new Map();
  }
 
  allow(playerId) {
    const now = Math.floor(Date.now() / 1000);
    const key = `${playerId}:${now}`;
    const count = (this.counters.get(key) || 0) + 1;
    this.counters.set(key, count);
 
    // Clean old entries
    for (const [k] of this.counters) {
      if (!k.endsWith(`:${now}`)) this.counters.delete(k);
    }
 
    return count <= this.maxPerSecond;
  }
}

Input Validation: Never trust client-side data. Validate all datagram payloads on the server to prevent cheating and injection attacks. Check that position values are within bounds, input sequences are monotonically increasing, and packet sizes are within expected ranges.

Conclusion

WebTransport brings native-level real-time communication to the web. For games, the combination of unreliable datagrams for position updates and reliable streams for critical events eliminates the compromises that WebSocket forces. For media, datagrams enable true low-latency streaming without the buffering required by HTTP-based approaches.

Key takeaways:

  1. Datagrams for positions and inputs: 60fps updates must not be blocked by packet loss
  2. Reliable streams for events: Chat, kills, and state transitions must arrive
  3. Client-side prediction: Apply inputs locally for responsive controls
  4. Binary formats: JSON is too expensive at high frame rates
  5. Interpolation: Smooth remote player movement by interpolating between states
  6. Adaptive quality: Monitor network conditions and adjust quality dynamically
  7. Separate channels: Never mix reliable and unreliable data on the same channel

If you are building a real-time game, cloud gaming service, or low-latency media platform for the web, WebTransport is the right choice. The performance advantages over WebSocket are substantial, and the API is designed specifically for the use cases that real-time applications need.