Skip to main content
This guide walks you through creating a complete ICE connection between two peers. We’ll build a “ping-pong” application where two ICE agents establish a connection and exchange messages.
This example demonstrates local peer-to-peer communication. Both agents run on the same machine, but the same principles apply to remote connections.

Overview

Establishing an ICE connection involves:
  1. Creating ICE agents on both sides
  2. Gathering local candidates (network addresses)
  3. Exchanging credentials and candidates with the remote peer
  4. Performing connectivity checks
  5. Using the established connection to send/receive data

Complete Example

Here’s a full working example that establishes an ICE connection between two peers:
1

Create the ICE agents

Each peer creates an Agent with network configuration:
agent, err := ice.NewAgentWithOptions(
    ice.WithNetworkTypes([]ice.NetworkType{ice.NetworkTypeUDP4}),
)
if err != nil {
    panic(err)
}
defer agent.Close()
WithNetworkTypes restricts which network protocols to use. Options include:
  • NetworkTypeUDP4 - IPv4 UDP (most common)
  • NetworkTypeUDP6 - IPv6 UDP
  • NetworkTypeTCP4 - IPv4 TCP
  • NetworkTypeTCP6 - IPv6 TCP
2

Set up event handlers

Register handlers for candidates and connection state changes:
// Handle discovered candidates
agent.OnCandidate(func(candidate ice.Candidate) {
    if candidate == nil {
        return // Gathering complete
    }
    
    // Send candidate to remote peer
    candidateStr := candidate.Marshal()
    sendToRemotePeer(candidateStr)
})

// Monitor connection state
agent.OnConnectionStateChange(func(state ice.ConnectionState) {
    fmt.Printf("Connection state changed: %s\n", state.String())
})
OnCandidate is called with nil when candidate gathering completes.
3

Exchange ICE credentials

Get local credentials and share them with the remote peer:
// Get local credentials
localUfrag, localPwd, err := agent.GetLocalUserCredentials()
if err != nil {
    panic(err)
}

// Send to remote peer and receive theirs
sendCredentials(localUfrag, localPwd)
remoteUfrag, remotePwd := receiveRemoteCredentials()
The username fragment (ufrag) and password (pwd) are used to authenticate connectivity checks.
4

Gather candidates

Start gathering network candidates:
err = agent.GatherCandidates()
if err != nil {
    panic(err)
}
This discovers all available network paths (local addresses, STUN reflexive addresses, TURN relay addresses).
5

Add remote candidates

As you receive candidates from the remote peer, add them to your agent:
// When you receive a candidate from remote peer
candidateStr := receiveFromRemotePeer()
candidate, err := ice.UnmarshalCandidate(candidateStr)
if err != nil {
    panic(err)
}

err = agent.AddRemoteCandidate(candidate)
if err != nil {
    panic(err)
}
6

Establish the connection

One peer acts as “controlling” and dials, the other “accepts”:
var conn *ice.Conn

if isControlling {
    // Controlling side initiates
    conn, err = agent.Dial(context.TODO(), remoteUfrag, remotePwd)
} else {
    // Controlled side waits
    conn, err = agent.Accept(context.TODO(), remoteUfrag, remotePwd)
}
if err != nil {
    panic(err)
}
defer conn.Close()
One peer must be controlling and the other controlled. Both cannot be the same role.
7

Send and receive data

Once connected, use the ice.Conn like any net.Conn:
// Send data
message := []byte("Hello, ICE!")
n, err := conn.Write(message)
if err != nil {
    panic(err)
}

// Receive data
buffer := make([]byte, 1500)
n, err = conn.Read(buffer)
if err != nil {
    panic(err)
}
fmt.Printf("Received: %s\n", string(buffer[:n]))

Full Working Example

Here’s a complete ping-pong example that demonstrates the entire flow. This example uses HTTP for signaling (exchanging credentials and candidates):
package main

import (
	"bufio"
	"context"
	"flag"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"time"

	"github.com/pion/ice/v4"
	"github.com/pion/randutil"
)

var (
	isControlling                 bool
	iceAgent                      *ice.Agent
	remoteAuthChannel             chan string
	localHTTPPort, remoteHTTPPort int
)

// HTTP handler to receive remote credentials
func remoteAuth(_ http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		panic(err)
	}
	remoteAuthChannel <- r.PostForm["ufrag"][0]
	remoteAuthChannel <- r.PostForm["pwd"][0]
}

// HTTP handler to receive remote candidates
func remoteCandidate(_ http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		panic(err)
	}

	c, err := ice.UnmarshalCandidate(r.PostForm["candidate"][0])
	if err != nil {
		panic(err)
	}

	if err := iceAgent.AddRemoteCandidate(c); err != nil {
		panic(err)
	}
}

func main() {
	remoteAuthChannel = make(chan string, 3)

	flag.BoolVar(&isControlling, "controlling", false, "is ICE Agent controlling")
	flag.Parse()

	// Configure ports based on role
	if isControlling {
		localHTTPPort = 9000
		remoteHTTPPort = 9001
	} else {
		localHTTPPort = 9001
		remoteHTTPPort = 9000
	}

	// Start HTTP server for signaling
	http.HandleFunc("/remoteAuth", remoteAuth)
	http.HandleFunc("/remoteCandidate", remoteCandidate)
	go func() {
		if err := http.ListenAndServe(fmt.Sprintf(":%d", localHTTPPort), nil); err != nil {
			panic(err)
		}
	}()

	fmt.Printf("Local Agent is %s\n", map[bool]string{true: "controlling", false: "controlled"}[isControlling])
	fmt.Print("Press 'Enter' when both processes have started")
	bufio.NewReader(os.Stdin).ReadBytes('\n')

	// Create ICE agent
	var err error
	iceAgent, err = ice.NewAgentWithOptions(
		ice.WithNetworkTypes([]ice.NetworkType{ice.NetworkTypeUDP4}),
	)
	if err != nil {
		panic(err)
	}

	// Handle candidates
	iceAgent.OnCandidate(func(c ice.Candidate) {
		if c == nil {
			return
		}
		_, err := http.PostForm(
			fmt.Sprintf("http://localhost:%d/remoteCandidate", remoteHTTPPort),
			url.Values{"candidate": {c.Marshal()}},
		)
		if err != nil {
			panic(err)
		}
	})

	// Monitor connection state
	iceAgent.OnConnectionStateChange(func(c ice.ConnectionState) {
		fmt.Printf("ICE Connection State: %s\n", c.String())
	})

	// Exchange credentials
	localUfrag, localPwd, err := iceAgent.GetLocalUserCredentials()
	if err != nil {
		panic(err)
	}

	_, err = http.PostForm(
		fmt.Sprintf("http://localhost:%d/remoteAuth", remoteHTTPPort),
		url.Values{"ufrag": {localUfrag}, "pwd": {localPwd}},
	)
	if err != nil {
		panic(err)
	}

	remoteUfrag := <-remoteAuthChannel
	remotePwd := <-remoteAuthChannel

	// Gather candidates
	if err = iceAgent.GatherCandidates(); err != nil {
		panic(err)
	}

	// Establish connection
	var conn *ice.Conn
	if isControlling {
		conn, err = iceAgent.Dial(context.TODO(), remoteUfrag, remotePwd)
	} else {
		conn, err = iceAgent.Accept(context.TODO(), remoteUfrag, remotePwd)
	}
	if err != nil {
		panic(err)
	}

	// Send messages
	go func() {
		for {
			time.Sleep(time.Second * 3)
			val, _ := randutil.GenerateCryptoRandomString(15, "abcdefghijklmnopqrstuvwxyz")
			if _, err := conn.Write([]byte(val)); err != nil {
				panic(err)
			}
			fmt.Printf("Sent: '%s'\n", val)
		}
	}()

	// Receive messages
	buf := make([]byte, 1500)
	for {
		n, err := conn.Read(buf)
		if err != nil {
			panic(err)
		}
		fmt.Printf("Received: '%s'\n", string(buf[:n]))
	}
}
Run the example: Open two terminals and run the program with and without the -controlling flag. Press Enter in both when ready, and watch the connection establish!

Key Concepts Explained

ICE Candidates

Candidates represent possible network paths:
  • Host candidates: Your local IP addresses and ports
  • Server reflexive candidates: Your public IP (discovered via STUN)
  • Relay candidates: TURN server addresses (for difficult NAT scenarios)

Controlling vs Controlled

ICE requires one peer to be “controlling” (initiates nomination) and the other “controlled” (responds). This is separate from client/server - either peer can be controlling.

Connection States

Watch for these states during connection:
ConnectionStateNew          // Initial state
ConnectionStateChecking     // Testing candidate pairs
ConnectionStateConnected    // Connection established
ConnectionStateCompleted    // All checks done

Adding STUN/TURN Servers

For connections across the internet, add STUN/TURN servers:
agent, err := ice.NewAgentWithOptions(
    ice.WithNetworkTypes([]ice.NetworkType{ice.NetworkTypeUDP4}),
    ice.WithUrls([]*stun.URI{
        stun.MustParseURI("stun:stun.l.google.com:19302"),
    }),
)
For relay (TURN) support:
import "github.com/pion/stun/v3"

turnURI := stun.MustParseURI("turn:turn.example.com:3478?transport=udp")
agent, err := ice.NewAgentWithOptions(
    ice.WithUrls([]*stun.URI{turnURI}),
)
TURN servers require authentication. Configure credentials in the URI or use WithTURNCredentials option.

Continual Gathering

For applications that need to adapt to network changes (like mobile apps), enable continual gathering:
agent, err := ice.NewAgentWithOptions(
    ice.WithContinualGatheringPolicy(ice.GatherContinually),
    ice.WithNetworkMonitorInterval(2 * time.Second),
)
This monitors network interfaces and gathers new candidates when networks change.

Next Steps

Agent Configuration

Learn about all available agent options

Candidate Types

Deep dive into ICE candidates

NAT Traversal

Configure STUN and TURN servers

Examples

Explore more code examples

Build docs developers (and LLMs) love