Skip to main content
ICE candidate gathering is the process of discovering and collecting local network addresses that can be used for peer-to-peer communication. This guide covers how gathering works, the different gathering modes, and how to handle candidates.

Starting Candidate Gathering

To begin gathering candidates, call GatherCandidates() after creating an agent:
agent, err := ice.NewAgentWithOptions(
    ice.WithNetworkTypes([]ice.NetworkType{ice.NetworkTypeUDP4}),
)
if err != nil {
    panic(err)
}

// Set candidate handler first
err = agent.OnCandidate(func(c ice.Candidate) {
    if c == nil {
        fmt.Println("Gathering complete")
        return
    }
    fmt.Printf("New candidate: %s\n", c)
})

// Start gathering
err = agent.GatherCandidates()
if err != nil {
    panic(err)
}
You must set the OnCandidate handler before calling GatherCandidates(), otherwise the agent will return ErrNoOnCandidateHandler.

Candidate Types

The ICE agent can gather three types of candidates:
1

Host Candidates

Local network addresses discovered from your network interfaces. These are gathered by enumerating network interfaces and binding to local ports.
ice.WithCandidateTypes([]ice.CandidateType{
    ice.CandidateTypeHost,
})
2

Server Reflexive Candidates

Public addresses discovered by sending STUN binding requests to STUN servers. The server returns your public IP and port as seen from the internet.
ice.WithCandidateTypes([]ice.CandidateType{
    ice.CandidateTypeServerReflexive,
})
3

Relay Candidates

Relayed addresses obtained from TURN servers. All traffic flows through the TURN server, ensuring connectivity even through restrictive NATs.
ice.WithCandidateTypes([]ice.CandidateType{
    ice.CandidateTypeRelay,
})

Gathering Process

The gathering process runs concurrently for all candidate types:
// From gather.go:196
func (a *Agent) gatherCandidatesInternal(ctx context.Context) {
    var wg sync.WaitGroup
    for _, t := range a.candidateTypes {
        switch t {
        case CandidateTypeHost:
            wg.Add(1)
            go func() {
                a.gatherCandidatesLocal(ctx, a.networkTypes)
                wg.Done()
            }()
        case CandidateTypeServerReflexive:
            a.gatherServerReflexiveCandidates(ctx, &wg)
        case CandidateTypeRelay:
            wg.Add(1)
            go func() {
                a.gatherCandidatesRelay(ctx, a.urls)
                wg.Done()
            }()
        }
    }
    wg.Wait()
}

Host Candidate Gathering

Host candidates are gathered by:
  1. Enumerating network interfaces
  2. Filtering based on interface and IP filters
  3. Binding UDP/TCP sockets to local ports
  4. Creating candidate objects with priority calculations
// Gather only from specific interfaces
agent, err := ice.NewAgentWithOptions(
    ice.WithInterfaceFilter(func(name string) bool {
        return strings.HasPrefix(name, "eth")
    }),
)

Server Reflexive Gathering

Server reflexive candidates are discovered by:
  1. Binding local UDP sockets
  2. Sending STUN binding requests to configured STUN servers
  3. Receiving XOR-MAPPED-ADDRESS responses
  4. Creating srflx candidates with the public address
urls := []*stun.URI{
    {Scheme: stun.SchemeTypeSTUN, Host: "stun.l.google.com", Port: 19302},
}

agent, err := ice.NewAgentWithOptions(
    ice.WithUrls(urls),
    ice.WithSTUNGatherTimeout(5 * time.Second),
)

Relay Candidate Gathering

Relay candidates are obtained by:
  1. Connecting to TURN servers via UDP, TCP, TLS, or DTLS
  2. Performing TURN allocation
  3. Receiving relayed transport address
  4. Creating relay candidates
urnURLs := []*stun.URI{
    {Scheme: stun.SchemeTypeTURN, Host: "turn.example.com", Port: 3478,
     Username: "user", Password: "pass"},
}

agent, err := ice.NewAgentWithOptions(
    ice.WithUrls(turnURLs),
)

Gathering States

The gathering process transitions through three states:
  1. New - Initial state before gathering starts
  2. Gathering - Actively gathering candidates
  3. Complete - All gathering finished (in GatherOnce mode)
state, err := agent.GetGatheringState()
fmt.Printf("Gathering state: %s\n", state)

Continual vs Single Gathering

Pion ICE supports two gathering policies:

GatherOnce (Default)

Gathering completes after the initial collection:
agent, err := ice.NewAgentWithOptions(
    ice.WithContinualGatheringPolicy(ice.GatherOnce),
)
The OnCandidate handler receives a nil candidate when gathering completes.

GatherContinually

Continuously monitors network interfaces and gathers new candidates as they appear:
agent, err := ice.NewAgentWithOptions(
    ice.WithContinualGatheringPolicy(ice.GatherContinually),
    ice.WithNetworkMonitorInterval(2 * time.Second),
)
Continual gathering is useful for mobile applications where network interfaces change frequently (switching between WiFi and cellular).

Network Monitoring

With continual gathering, the agent periodically checks for network changes:
// From gather.go:1206
func (a *Agent) startNetworkMonitoring(ctx context.Context) {
    ticker := time.NewTicker(a.networkMonitorInterval)
    defer ticker.Stop()
    
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if a.detectNetworkChanges() {
                a.gatherCandidatesInternal(ctx)
            }
        }
    }
}

mDNS Candidates

For local network privacy, ICE can use mDNS hostnames instead of IP addresses:
agent, err := ice.NewAgentWithOptions(
    ice.WithMulticastDNSMode(ice.MulticastDNSModeQueryAndGather),
    ice.WithMulticastDNSHostName("device.local"),
)

Multicast DNS Modes

  • MulticastDNSModeDisabled - No mDNS support (default)
  • MulticastDNSModeQueryOnly - Resolve mDNS candidates from remote peer
  • MulticastDNSModeQueryAndGather - Gather and resolve mDNS candidates
When using MulticastDNSModeQueryAndGather, host candidates will advertise .local hostnames instead of IP addresses, hiding location-tracking information.

Handling Candidates

The OnCandidate handler is called for each discovered candidate:
var candidates []ice.Candidate

err = agent.OnCandidate(func(c ice.Candidate) {
    if c == nil {
        // Gathering complete (in GatherOnce mode)
        fmt.Printf("Gathered %d candidates\n", len(candidates))
        return
    }
    
    // New candidate discovered
    candidates = append(candidates, c)
    
    // Send to remote peer via signaling
    sendToRemote(c.Marshal())
})

Location Tracking Prevention

ICE automatically filters certain candidates to prevent location tracking:
// From gather.go:432
func shouldFilterLocationTrackedIP(candidateIP netip.Addr) bool {
    // IPv6 link-local addresses are filtered when using privacy-preserving
    // address generation (RFC 8445 Section 5.1.1.1)
    return candidateIP.Is6() && 
           (candidateIP.IsLinkLocalUnicast() || candidateIP.IsLinkLocalMulticast())
}
Link-local IPv6 addresses are filtered when gathering candidates that use privacy mechanisms.

Example: Continual Gathering

Here’s a complete example demonstrating continual gathering:
import (
    "context"
    "fmt"
    "time"
    "github.com/pion/ice/v4"
)

func main() {
    agent, err := ice.NewAgentWithOptions(
        ice.WithNetworkTypes([]ice.NetworkType{
            ice.NetworkTypeUDP4,
            ice.NetworkTypeUDP6,
        }),
        ice.WithCandidateTypes([]ice.CandidateType{
            ice.CandidateTypeHost,
        }),
        ice.WithContinualGatheringPolicy(ice.GatherContinually),
        ice.WithNetworkMonitorInterval(2 * time.Second),
    )
    if err != nil {
        panic(err)
    }
    defer agent.Close()
    
    // Track candidates
    candidateCount := 0
    err = agent.OnCandidate(func(c ice.Candidate) {
        if c == nil {
            return // No completion signal in continual mode
        }
        candidateCount++
        fmt.Printf("[%d] %s\n", candidateCount, c)
    })
    if err != nil {
        panic(err)
    }
    
    // Start gathering
    err = agent.GatherCandidates()
    if err != nil {
        panic(err)
    }
    
    // Monitor gathering state
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()
    
    for {
        <-ticker.C
        state, _ := agent.GetGatheringState()
        candidates, _ := agent.GetLocalCandidates()
        fmt.Printf("State: %s, Candidates: %d\n", state, len(candidates))
    }
}

Troubleshooting

No Candidates Gathered

  • Verify network types are enabled: WithNetworkTypes()
  • Check interface filters aren’t too restrictive
  • Enable debug logging to see why interfaces are skipped

STUN/TURN Failures

  • Verify server URLs are correct
  • Check firewall allows UDP/TCP to STUN/TURN ports
  • Increase STUN gather timeout: WithSTUNGatherTimeout(10 * time.Second)
  • Check TURN credentials are valid

Gathering Never Completes

  • Ensure OnCandidate handler is set before GatherCandidates()
  • Check for network connectivity issues
  • Review logs for errors during gathering

Next Steps

Connectivity Checks

Learn how ICE performs connectivity checks between candidates

NAT Traversal

Configure address rewriting for NAT traversal

Multiplexing

Share UDP/TCP ports across multiple ICE sessions

Examples

See gathering examples in action

Build docs developers (and LLMs) love