ContactAbout
Intermediate
20 min
Full Guide

WebRTC Data Channels

Send arbitrary data peer-to-peer with low latency using RTCDataChannel

What are Data Channels?

RTCDataChannel enables peer-to-peer transfer of arbitrary dataโ€”text, binary, files, game stateโ€”directly between browsers. Unlike WebSocket which requires a server, data channels let you send data with ultra-low latency directly to another user.

Data channels are perfect for multiplayer games, file sharing, collaborative tools, and any application where you need fast, direct communication between users.

๐ŸŽฎ Data Channel Use Cases

Gaming

  • โ€ข Player position/state sync
  • โ€ข Real-time input transmission
  • โ€ข Game events and actions

File Transfer

  • โ€ข Peer-to-peer file sharing
  • โ€ข No server upload needed
  • โ€ข Large file support with chunking

Collaboration

  • โ€ข Cursor positions
  • โ€ข Document edits (CRDT sync)
  • โ€ข Drawing/whiteboard strokes

Chat

  • โ€ข Text messages
  • โ€ข Typing indicators
  • โ€ข Read receipts

Creating Data Channels

// Data channels are created through RTCPeerConnection

const pc = new RTCPeerConnection(config);

// Create a data channel (initiating peer)
const channel = pc.createDataChannel('messages', {
  ordered: true,           // Guarantee order (like TCP)
  maxRetransmits: 3,       // Max resend attempts
  // OR
  maxPacketLifeTime: 3000, // Max ms to retransmit
  
  protocol: '',            // Sub-protocol (optional)
  negotiated: false,       // Auto-negotiate (default)
  id: undefined            // Channel ID (auto if not set)
});

// Channel events
channel.onopen = () => {
  console.log('Data channel opened!');
  channel.send('Hello, peer!');
};

channel.onmessage = (event) => {
  console.log('Received:', event.data);
};

channel.onerror = (error) => {
  console.error('Channel error:', error);
};

channel.onclose = () => {
  console.log('Data channel closed');
};

// Receiving peer gets channel via ondatachannel
pc.ondatachannel = (event) => {
  const receivedChannel = event.channel;
  console.log('Received channel:', receivedChannel.label);
  
  receivedChannel.onmessage = (e) => {
    console.log('Message:', e.data);
  };
};

// Check channel state
console.log(channel.readyState);
// 'connecting' | 'open' | 'closing' | 'closed'

console.log(channel.bufferedAmount); // Bytes queued to send

Channel Configuration Options

// Ordered vs Unordered delivery

// ORDERED (default) - Messages arrive in order sent
// Best for: Chat, file transfer, transactions
const orderedChannel = pc.createDataChannel('chat', {
  ordered: true
});

// UNORDERED - Messages may arrive out of order
// Best for: Games, real-time position updates
const unorderedChannel = pc.createDataChannel('game-state', {
  ordered: false
});

// Reliability options (can't use both together)

// RELIABLE (default) - Retransmit until delivered
const reliableChannel = pc.createDataChannel('files', {
  ordered: true
  // No maxRetransmits or maxPacketLifeTime = reliable
});

// PARTIALLY RELIABLE - Limited retransmissions
const partialChannel = pc.createDataChannel('positions', {
  ordered: false,
  maxRetransmits: 0  // Send once, no retransmit (UDP-like)
});

// TIME-LIMITED - Retransmit for N milliseconds
const timedChannel = pc.createDataChannel('voice-commands', {
  ordered: false,
  maxPacketLifeTime: 500  // Give up after 500ms
});

// Common configurations:

// For chat/messaging (reliable, ordered)
const chatChannel = pc.createDataChannel('chat', {
  ordered: true
});

// For game state updates (fast, unordered)
const gameChannel = pc.createDataChannel('game', {
  ordered: false,
  maxRetransmits: 0
});

// For file transfer (reliable, ordered)
const fileChannel = pc.createDataChannel('files', {
  ordered: true
});

// For cursor positions (partially reliable)
const cursorChannel = pc.createDataChannel('cursors', {
  ordered: false,
  maxRetransmits: 1
});

Sending Different Data Types

// Data channels support text and binary data

// STRING
channel.send('Hello, World!');
channel.send(JSON.stringify({ type: 'chat', message: 'Hi!' }));

// ARRAYBUFFER
const buffer = new ArrayBuffer(256);
const view = new Uint8Array(buffer);
view[0] = 42;
channel.send(buffer);

// TYPED ARRAYS
const floats = new Float32Array([1.0, 2.5, 3.14]);
channel.send(floats);

// BLOB
const blob = new Blob(['file contents'], { type: 'text/plain' });
channel.send(blob);

// Set binary type for receiving
channel.binaryType = 'arraybuffer'; // or 'blob'

channel.onmessage = (event) => {
  if (typeof event.data === 'string') {
    const message = JSON.parse(event.data);
    handleTextMessage(message);
  } else if (event.data instanceof ArrayBuffer) {
    handleBinaryData(event.data);
  } else if (event.data instanceof Blob) {
    handleBlob(event.data);
  }
};

// Efficient binary protocol for games
function sendPlayerState(x, y, rotation, health) {
  const buffer = new ArrayBuffer(16);
  const view = new DataView(buffer);
  view.setFloat32(0, x);
  view.setFloat32(4, y);
  view.setFloat32(8, rotation);
  view.setUint32(12, health);
  channel.send(buffer);
}

function receivePlayerState(buffer) {
  const view = new DataView(buffer);
  return {
    x: view.getFloat32(0),
    y: view.getFloat32(4),
    rotation: view.getFloat32(8),
    health: view.getUint32(12)
  };
}

File Transfer with Data Channels

// Complete peer-to-peer file transfer

class P2PFileTransfer {
  constructor(dataChannel) {
    this.channel = dataChannel;
    this.channel.binaryType = 'arraybuffer';
    this.receivingFile = null;
    this.receivedChunks = [];
    this.chunkSize = 64 * 1024; // 64KB chunks
    
    this.channel.onmessage = (e) => this.handleMessage(e);
    
    // Callbacks
    this.onProgress = () => {};
    this.onFileReceived = () => {};
  }
  
  async sendFile(file) {
    // Send metadata first
    this.channel.send(JSON.stringify({
      type: 'file-start',
      name: file.name,
      size: file.size,
      mimeType: file.type
    }));
    
    // Read and send in chunks
    const buffer = await file.arrayBuffer();
    const totalChunks = Math.ceil(buffer.byteLength / this.chunkSize);
    
    for (let i = 0; i < totalChunks; i++) {
      // Wait if buffer is full (flow control)
      while (this.channel.bufferedAmount > 1024 * 1024) {
        await new Promise(r => setTimeout(r, 50));
      }
      
      const start = i * this.chunkSize;
      const end = Math.min(start + this.chunkSize, buffer.byteLength);
      const chunk = buffer.slice(start, end);
      
      this.channel.send(chunk);
      this.onProgress(i + 1, totalChunks, 'sending');
    }
    
    // Send completion marker
    this.channel.send(JSON.stringify({ type: 'file-end' }));
  }
  
  handleMessage(event) {
    if (typeof event.data === 'string') {
      const msg = JSON.parse(event.data);
      
      if (msg.type === 'file-start') {
        this.receivingFile = {
          name: msg.name,
          size: msg.size,
          mimeType: msg.mimeType
        };
        this.receivedChunks = [];
      } else if (msg.type === 'file-end') {
        this.assembleFile();
      }
    } else {
      // Binary chunk
      this.receivedChunks.push(event.data);
      const received = this.receivedChunks.reduce((a, b) => a + b.byteLength, 0);
      const total = this.receivingFile?.size || 0;
      this.onProgress(received, total, 'receiving');
    }
  }
  
  assembleFile() {
    const blob = new Blob(this.receivedChunks, {
      type: this.receivingFile.mimeType
    });
    
    // Create download link
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = this.receivingFile.name;
    a.click();
    URL.revokeObjectURL(url);
    
    this.onFileReceived(this.receivingFile.name, blob);
    this.receivingFile = null;
    this.receivedChunks = [];
  }
}

// Usage
const fileTransfer = new P2PFileTransfer(dataChannel);

fileTransfer.onProgress = (current, total, direction) => {
  const percent = Math.round((current / total) * 100);
  console.log(`${direction}: ${percent}%`);
};

fileTransfer.onFileReceived = (name, blob) => {
  console.log(`Received: ${name} (${blob.size} bytes)`);
};

// Send a file
const fileInput = document.getElementById('fileInput');
fileInput.onchange = () => {
  fileTransfer.sendFile(fileInput.files[0]);
};

Multiplayer Game Example

// Real-time game state sync over data channel

class MultiplayerSync {
  constructor(pc) {
    this.pc = pc;
    this.players = new Map();
    this.localPlayerId = null;
    
    // Create channels for different purposes
    this.stateChannel = pc.createDataChannel('game-state', {
      ordered: false,
      maxRetransmits: 0 // Fast, unreliable
    });
    
    this.eventChannel = pc.createDataChannel('game-events', {
      ordered: true // Reliable for important events
    });
    
    this.setupChannels();
  }
  
  setupChannels() {
    this.stateChannel.onopen = () => {
      console.log('State channel ready');
      this.startStateSync();
    };
    
    this.stateChannel.onmessage = (e) => {
      const state = this.decodeState(e.data);
      this.updateRemotePlayer(state);
    };
    
    this.eventChannel.onmessage = (e) => {
      const event = JSON.parse(e.data);
      this.handleGameEvent(event);
    };
  }
  
  startStateSync() {
    // Send local player state 60 times per second
    setInterval(() => {
      if (this.stateChannel.readyState === 'open') {
        const state = this.getLocalPlayerState();
        this.stateChannel.send(this.encodeState(state));
      }
    }, 1000 / 60);
  }
  
  // Efficient binary encoding for position updates
  encodeState(state) {
    const buffer = new ArrayBuffer(28);
    const view = new DataView(buffer);
    
    view.setUint32(0, state.playerId);
    view.setFloat32(4, state.x);
    view.setFloat32(8, state.y);
    view.setFloat32(12, state.z);
    view.setFloat32(16, state.rotation);
    view.setUint32(20, state.timestamp);
    view.setUint8(24, state.animation);
    view.setUint8(25, state.health);
    
    return buffer;
  }
  
  decodeState(buffer) {
    const view = new DataView(buffer);
    return {
      playerId: view.getUint32(0),
      x: view.getFloat32(4),
      y: view.getFloat32(8),
      z: view.getFloat32(12),
      rotation: view.getFloat32(16),
      timestamp: view.getUint32(20),
      animation: view.getUint8(24),
      health: view.getUint8(25)
    };
  }
  
  updateRemotePlayer(state) {
    // Apply interpolation/prediction for smooth movement
    let player = this.players.get(state.playerId);
    
    if (!player) {
      player = this.createPlayer(state.playerId);
      this.players.set(state.playerId, player);
    }
    
    // Interpolate position
    player.targetX = state.x;
    player.targetY = state.y;
    player.targetZ = state.z;
    player.targetRotation = state.rotation;
    player.health = state.health;
    player.lastUpdate = Date.now();
  }
  
  // Reliable events (damage, pickups, etc.)
  sendGameEvent(event) {
    if (this.eventChannel.readyState === 'open') {
      this.eventChannel.send(JSON.stringify(event));
    }
  }
  
  handleGameEvent(event) {
    switch (event.type) {
      case 'damage':
        this.applyDamage(event.targetId, event.amount);
        break;
      case 'pickup':
        this.handlePickup(event.itemId, event.playerId);
        break;
      case 'spawn':
        this.spawnPlayer(event.playerId, event.position);
        break;
    }
  }
}

// Usage
const sync = new MultiplayerSync(peerConnection);
sync.localPlayerId = myPlayerId;

// In game loop
function gameLoop() {
  // Interpolate remote players
  sync.players.forEach(player => {
    player.x += (player.targetX - player.x) * 0.1;
    player.y += (player.targetY - player.y) * 0.1;
    renderPlayer(player);
  });
  
  requestAnimationFrame(gameLoop);
}

// Send events
sync.sendGameEvent({
  type: 'damage',
  targetId: enemyId,
  amount: 25
});

Flow Control and Backpressure

// Handle bufferedAmount to avoid overwhelming the channel

async function sendWithFlowControl(channel, data) {
  // Check if buffer is getting full
  const maxBuffer = 1024 * 1024; // 1MB threshold
  
  if (channel.bufferedAmount > maxBuffer) {
    // Wait for buffer to drain
    await new Promise(resolve => {
      channel.bufferedAmountLowThreshold = maxBuffer / 2;
      channel.onbufferedamountlow = resolve;
    });
  }
  
  channel.send(data);
}

// Throttle high-frequency updates
class ThrottledChannel {
  constructor(channel, minInterval = 16) { // ~60fps
    this.channel = channel;
    this.minInterval = minInterval;
    this.lastSend = 0;
    this.pendingData = null;
    this.timeout = null;
  }
  
  send(data) {
    const now = Date.now();
    const elapsed = now - this.lastSend;
    
    if (elapsed >= this.minInterval) {
      this.doSend(data);
    } else {
      // Store latest and schedule
      this.pendingData = data;
      
      if (!this.timeout) {
        this.timeout = setTimeout(() => {
          this.doSend(this.pendingData);
          this.pendingData = null;
          this.timeout = null;
        }, this.minInterval - elapsed);
      }
    }
  }
  
  doSend(data) {
    if (this.channel.readyState === 'open') {
      this.channel.send(data);
      this.lastSend = Date.now();
    }
  }
}

// Usage
const throttled = new ThrottledChannel(positionChannel, 16);

// Called on every mouse move - throttled to 60fps
document.onmousemove = (e) => {
  throttled.send(JSON.stringify({ x: e.clientX, y: e.clientY }));
};

๐Ÿ’ก Data Channel Best Practices

  • โœ“ Use unordered for real-time - Games and cursors don't need ordering
  • โœ“ Use binary for efficiency - DataView for structured binary data
  • โœ“ Monitor bufferedAmount - Implement flow control
  • โœ“ Chunk large files - 16-64KB chunks work well
  • โœ“ Separate channels - Different channels for different data types
  • โœ“ Handle channel close - Graceful degradation