TechLead
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

Continue Learning