Skip to main content

Overview

This guide walks you through creating a simple WebRTC data channel application. You’ll build a Go server that:
  • Creates a PeerConnection
  • Establishes a DataChannel
  • Exchanges messages with a web browser
  • Handles ICE candidates and signaling
Data channels are perfect for real-time text communication, gaming, file transfer, or any low-latency data exchange.

What You’ll Build

By the end of this guide, you’ll have a working WebRTC server that:
  1. Accepts connections from web browsers
  2. Creates bidirectional data channels
  3. Sends and receives text messages in real-time
  4. Handles ICE gathering and signaling automatically

Prerequisites

Before starting, make sure you have:
  • Go 1.24.0 or later installed
  • Pion WebRTC v4 installed (Installation Guide)
  • Basic understanding of Go
  • A web browser for testing

Step 1: Create Your Project

1

Initialize Project

mkdir webrtc-datachannel
cd webrtc-datachannel
go mod init webrtc-datachannel
2

Install Pion WebRTC

go get github.com/pion/webrtc/v4

Step 2: Create the Server

Create a file named main.go with the following code:
package main

import (
	"encoding/json"
	"fmt"
	"net/http"

	"github.com/pion/webrtc/v4"
)

func main() {
	var pc *webrtc.PeerConnection

	setupOfferHandler(&pc)
	setupCandidateHandler(&pc)
	setupStaticHandler()

	fmt.Println("🚀 Server started on http://localhost:8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		fmt.Printf("Failed to start server: %v\n", err)
	}
}

func setupOfferHandler(pc **webrtc.PeerConnection) {
	http.HandleFunc("/offer", func(w http.ResponseWriter, r *http.Request) {
		var offer webrtc.SessionDescription
		if err := json.NewDecoder(r.Body).Decode(&offer); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

		// Create PeerConnection with STUN server
		var err error
		*pc, err = webrtc.NewPeerConnection(webrtc.Configuration{
			ICEServers: []webrtc.ICEServer{
				{URLs: []string{"stun:stun.l.google.com:19302"}},
			},
		})
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		setupICECandidateHandler(*pc)
		setupDataChannelHandler(*pc)

		if err := processOffer(*pc, offer, w); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
	})
}

func setupICECandidateHandler(pc *webrtc.PeerConnection) {
	pc.OnICECandidate(func(c *webrtc.ICECandidate) {
		if c != nil {
			fmt.Printf("🌐 New ICE candidate: %s\n", c.Address)
		}
	})
}

func setupDataChannelHandler(pc *webrtc.PeerConnection) {
	pc.OnDataChannel(func(d *webrtc.DataChannel) {
		d.OnOpen(func() {
			fmt.Println("✅ DataChannel opened")
			if err := d.SendText("Hello from Go server!"); err != nil {
				fmt.Printf("Send error: %v\n", err)
			}
		})
		d.OnMessage(func(msg webrtc.DataChannelMessage) {
			fmt.Printf("📩 Received: %s\n", string(msg.Data))
		})
	})
}

func processOffer(
	pc *webrtc.PeerConnection,
	offer webrtc.SessionDescription,
	w http.ResponseWriter,
) error {
	// Set remote description
	if err := pc.SetRemoteDescription(offer); err != nil {
		return err
	}

	// Create answer
	answer, err := pc.CreateAnswer(nil)
	if err != nil {
		return err
	}

	// Set local description
	if err := pc.SetLocalDescription(answer); err != nil {
		return err
	}

	// Wait for ICE gathering to complete
	gatherComplete := webrtc.GatheringCompletePromise(pc)
	<-gatherComplete

	finalAnswer := pc.LocalDescription()
	if finalAnswer == nil {
		return fmt.Errorf("local description is nil")
	}

	w.Header().Set("Content-Type", "application/json")
	return json.NewEncoder(w).Encode(*finalAnswer)
}

func setupCandidateHandler(pc **webrtc.PeerConnection) {
	http.HandleFunc("/candidate", func(w http.ResponseWriter, r *http.Request) {
		var candidate webrtc.ICECandidateInit
		if err := json.NewDecoder(r.Body).Decode(&candidate); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		if *pc != nil {
			if err := (*pc).AddICECandidate(candidate); err != nil {
				fmt.Println("Failed to add candidate:", err)
			}
		}
	})
}

func setupStaticHandler() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html")
		fmt.Fprint(w, indexHTML)
	})
}

const indexHTML = `<!DOCTYPE html>
<html>
<head>
    <title>Pion WebRTC Data Channel</title>
    <style>
        body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
        #messages { border: 1px solid #ccc; height: 300px; overflow-y: scroll; padding: 10px; margin: 20px 0; }
        button { padding: 10px 20px; margin: 5px; cursor: pointer; }
        input { padding: 10px; width: 300px; }
        .message { margin: 5px 0; padding: 5px; }
        .sent { color: blue; }
        .received { color: green; }
    </style>
</head>
<body>
    <h1>🚀 Pion WebRTC Data Channel Demo</h1>
    <button onclick="connect()">Connect</button>
    <div id="status">Status: Disconnected</div>
    <div id="messages"></div>
    <input id="messageInput" type="text" placeholder="Type a message..." disabled />
    <button onclick="sendMessage()" disabled id="sendBtn">Send</button>

    <script>
        let pc = null;
        let dataChannel = null;

        async function connect() {
            pc = new RTCPeerConnection({
                iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
            });

            pc.onicecandidate = e => {
                if (e.candidate) {
                    fetch('/candidate', {
                        method: 'POST',
                        body: JSON.stringify(e.candidate)
                    });
                }
            };

            dataChannel = pc.createDataChannel('data');
            dataChannel.onopen = () => {
                document.getElementById('status').textContent = 'Status: Connected ✅';
                document.getElementById('messageInput').disabled = false;
                document.getElementById('sendBtn').disabled = false;
                addMessage('Connected to server!', 'system');
            };

            dataChannel.onmessage = e => {
                addMessage(e.data, 'received');
            };

            const offer = await pc.createOffer();
            await pc.setLocalDescription(offer);

            const response = await fetch('/offer', {
                method: 'POST',
                body: JSON.stringify(offer)
            });
            const answer = await response.json();
            await pc.setRemoteDescription(answer);
        }

        function sendMessage() {
            const input = document.getElementById('messageInput');
            const message = input.value;
            if (message && dataChannel && dataChannel.readyState === 'open') {
                dataChannel.send(message);
                addMessage(message, 'sent');
                input.value = '';
            }
        }

        function addMessage(text, type) {
            const div = document.createElement('div');
            div.className = 'message ' + type;
            div.textContent = (type === 'sent' ? '→ ' : '← ') + text;
            document.getElementById('messages').appendChild(div);
        }

        document.getElementById('messageInput').addEventListener('keypress', e => {
            if (e.key === 'Enter') sendMessage();
        });
    </script>
</body>
</html>`

Step 3: Understanding the Code

Let’s break down the key components:
config := webrtc.Configuration{
    ICEServers: []webrtc.ICEServer{
        {URLs: []string{"stun:stun.l.google.com:19302"}},
    },
}
pc, err := webrtc.NewPeerConnection(config)
The Configuration specifies STUN servers for NAT traversal. Google’s public STUN server helps establish connections through firewalls.
pc.OnDataChannel(func(d *webrtc.DataChannel) {
    d.OnOpen(func() {
        // Channel is ready to send/receive
        d.SendText("Hello!")
    })
    d.OnMessage(func(msg webrtc.DataChannelMessage) {
        // Handle incoming messages
        fmt.Printf("Received: %s\n", string(msg.Data))
    })
})
Event handlers respond to channel lifecycle events and incoming messages.
// Set remote description (offer from browser)
pc.SetRemoteDescription(offer)

// Create answer
answer, err := pc.CreateAnswer(nil)

// Set local description
pc.SetLocalDescription(answer)

// Wait for ICE gathering
gatherComplete := webrtc.GatheringCompletePromise(pc)
<-gatherComplete
This implements the SDP offer/answer exchange for WebRTC signaling.
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
    if c != nil {
        // New ICE candidate discovered
        fmt.Printf("ICE candidate: %s\n", c.Address)
    }
})
ICE candidates represent possible connection paths. In this example, we use complete ICE gathering instead of trickle ICE for simplicity.

Step 4: Run Your Application

1

Start the Server

go run main.go
You should see:
🚀 Server started on http://localhost:8080
2

Open in Browser

Navigate to http://localhost:8080 in your web browser.
3

Connect

Click the “Connect” button. You’ll see the connection establish in real-time.
4

Send Messages

Type a message and click “Send” or press Enter. Watch messages flow bidirectionally!

Expected Output

When you connect, the server terminal will show:
🌐 New ICE candidate: 192.168.1.100
 DataChannel opened
📩 Received: Hello from browser!
The browser will display:
Status: Connected ✅
← Hello from Go server!
→ Hello from browser!

More Complex Example: Pion-to-Pion

For server-to-server communication, check out this example from the Pion repository:
package main

import (
	"github.com/pion/webrtc/v4"
	"time"
)

func main() {
	// Configure PeerConnection
	config := webrtc.Configuration{
		ICEServers: []webrtc.ICEServer{
			{URLs: []string{"stun:stun.l.google.com:19302"}},
		},
	}

	// Create PeerConnection
	pc, err := webrtc.NewPeerConnection(config)
	if err != nil {
		panic(err)
	}
	defer pc.Close()

	// Create DataChannel
	dataChannel, err := pc.CreateDataChannel("data", nil)
	if err != nil {
		panic(err)
	}

	// Set up handlers
	dataChannel.OnOpen(func() {
		fmt.Println("Data channel opened")
		
		// Send periodic messages
		ticker := time.NewTicker(5 * time.Second)
		defer ticker.Stop()
		
		for range ticker.C {
			message := "Hello from Pion!"
			fmt.Printf("Sending: %s\n", message)
			dataChannel.SendText(message)
		}
	})

	dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
		fmt.Printf("Received: %s\n", string(msg.Data))
	})

	// Create offer
	offer, err := pc.CreateOffer(nil)
	if err != nil {
		panic(err)
	}

	// Set local description
	if err = pc.SetLocalDescription(offer); err != nil {
		panic(err)
	}

	// Exchange SDP with answer side...
	// (See full example in repository)
}
Find the complete pion-to-pion example with full signaling implementation at: github.com/pion/webrtc/tree/master/examples/pion-to-pion

Key Concepts Explained

PeerConnection Lifecycle

1

New

PeerConnection is created but no connection attempt started
2

Connecting

ICE candidates are being gathered and connectivity checks are running
3

Connected

At least one ICE candidate pair succeeded and media can flow
4

Failed

Connection couldn’t be established (may be recoverable with ICE restart)
5

Closed

Connection was explicitly closed
Monitor the connection state:
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
    fmt.Printf("Connection State: %s\n", state.String())
    
    if state == webrtc.PeerConnectionStateFailed {
        // Handle failure
    }
})

DataChannel Options

Customize DataChannel behavior:
// Ordered, reliable (default - like TCP)
dc, _ := pc.CreateDataChannel("reliable", nil)

// Unordered, unreliable (like UDP)
dc, _ := pc.CreateDataChannel("unreliable", &webrtc.DataChannelInit{
    Ordered: ptrBool(false),
    MaxRetransmits: ptrUint16(0),
})

// Partially reliable (max 3 retransmits)
dc, _ := pc.CreateDataChannel("partial", &webrtc.DataChannelInit{
    MaxRetransmits: ptrUint16(3),
})

func ptrBool(b bool) *bool { return &b }
func ptrUint16(u uint16) *uint16 { return &u }
DataChannel Types:
  • Ordered + Reliable - Guaranteed delivery in order (default)
  • Unordered + Unreliable - Best for real-time gaming, live streams
  • Partially Reliable - Balance between reliability and latency

Signaling Patterns

This quickstart uses HTTP for signaling, but production apps typically use:
  • WebSocket - For real-time bidirectional signaling
  • WHIP/WHEP - Standardized HTTP-based signaling
  • Custom Protocol - Over any transport (Redis, MQTT, etc.)

Next Steps

Send Media

Learn to send audio and video from files or live sources

Receive Media

Capture and save incoming audio/video streams

Advanced Examples

Explore simulcast, renegotiation, stats, and more

API Reference

Deep dive into the complete Pion WebRTC API

Common Patterns

Broadcasting to Multiple Peers

type Broadcaster struct {
    peers map[string]*webrtc.PeerConnection
    mu    sync.RWMutex
}

func (b *Broadcaster) Broadcast(message string) {
    b.mu.RLock()
    defer b.mu.RUnlock()
    
    for id, pc := range b.peers {
        // Get all data channels for this peer
        // Send message to each
    }
}

Handling Connection Failures

pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
    switch state {
    case webrtc.PeerConnectionStateFailed:
        // Try ICE restart
        offer, _ := pc.CreateOffer(&webrtc.OfferOptions{
            ICERestart: true,
        })
        pc.SetLocalDescription(offer)
        
    case webrtc.PeerConnectionStateClosed:
        // Clean up resources
    }
})

Binary Data Transfer

// Send binary data
data := []byte{0x01, 0x02, 0x03, 0x04}
dataChannel.Send(data)

// Receive binary data
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
    if msg.IsString {
        fmt.Printf("Text: %s\n", string(msg.Data))
    } else {
        fmt.Printf("Binary: %v\n", msg.Data)
    }
})

Troubleshooting

  • Verify STUN server is reachable
  • Check firewall settings
  • Ensure ICE candidates are being exchanged
  • Try adding a TURN server for relay
  • Confirm both sides have completed SDP exchange
  • Check that SetRemoteDescription was called
  • Verify ICE gathering completed before exchanging SDP
  • Look for errors in OnDataChannel handler
  • Ensure DataChannel.OnMessage is set before channel opens
  • Check DataChannel.ReadyState (should be ‘open’)
  • Verify no errors from SendText/Send
  • Check for SCTP congestion or flow control
  • Use unordered, unreliable channels for real-time data
  • Consider adjusting MaxRetransmits
  • Check network conditions with stats
  • Monitor DataChannel.BufferedAmount
Production Checklist:
  • Implement proper error handling
  • Use secure signaling (WSS, HTTPS)
  • Add authentication/authorization
  • Configure TURN servers for NAT traversal
  • Monitor connection statistics
  • Handle reconnection logic
  • Clean up resources on close

Learn More

WebRTC for the Curious

Understand WebRTC protocols at a deep level: ICE, DTLS, SCTP, RTP, and more
Join the Community: Happy coding! 🚀

Build docs developers (and LLMs) love