Skip to main content
ICE connectivity checks verify which candidate pairs can successfully communicate. This guide covers how connectivity checks work, the nomination process, and how to configure check behavior.

Overview

After gathering candidates, the ICE agent performs connectivity checks to find working candidate pairs:
1

Pairing

Local and remote candidates are paired together based on component and foundation
2

Prioritization

Pairs are sorted by priority (controlling agent uses local + remote priority)
3

Connectivity Checks

STUN binding requests are sent to test each pair
4

Nomination

The controlling agent nominates a successful pair for use

Check Interval

The check interval controls how frequently the agent performs connectivity checks:
agent, err := ice.NewAgentWithOptions(
    ice.WithCheckInterval(200 * time.Millisecond), // default
)
The default check interval is 200ms as defined in agent_config.go:18.

Controlling vs Controlled

ICE agents operate in two roles:

Controlling Agent

The controlling agent:
  • Nominates candidate pairs using the USE-CANDIDATE attribute
  • Waits for minimum acceptance times before nomination
  • Includes the ICE-CONTROLLING attribute in binding requests
conn, err := agent.Dial(ctx, remoteUfrag, remotePwd)

Controlled Agent

The controlled agent:
  • Accepts nominations from the controlling agent
  • Responds to binding requests with binding responses
  • Includes the ICE-CONTROLLED attribute in binding requests
conn, err := agent.Accept(ctx, remoteUfrag, remotePwd)

Candidate Pair States

Candidate pairs transition through several states:
  1. Waiting - Pair created, waiting to be checked
  2. In Progress - Check in progress
  3. Succeeded - Check succeeded, pair is valid
  4. Failed - Check failed after max binding requests

Nomination Process

The controlling agent nominates pairs based on candidate type acceptance wait times:
agent, err := ice.NewAgentWithOptions(
    ice.WithHostAcceptanceMinWait(0 * time.Millisecond),      // default: 0ms
    ice.WithSrflxAcceptanceMinWait(500 * time.Millisecond),   // default: 500ms
    ice.WithPrflxAcceptanceMinWait(1000 * time.Millisecond),  // default: 1s
    ice.WithRelayAcceptanceMinWait(2000 * time.Millisecond),  // default: 2s
)

Nomination Logic

From selection.go:34:
func (s *controllingSelector) isNominatable(c Candidate) bool {
    switch {
    case c.Type() == CandidateTypeHost:
        return time.Since(s.startTime) > s.agent.hostAcceptanceMinWait
    case c.Type() == CandidateTypeServerReflexive:
        return time.Since(s.startTime) > s.agent.srflxAcceptanceMinWait
    case c.Type() == CandidateTypePeerReflexive:
        return time.Since(s.startTime) > s.agent.prflxAcceptanceMinWait
    case c.Type() == CandidateTypeRelay:
        return time.Since(s.startTime) > s.agent.relayAcceptanceMinWait
    }
    return false
}
This allows you to configure how quickly each candidate type can be nominated.
Increase relay acceptance wait time to give direct connections more time to establish before falling back to TURN.

Binding Requests

Connectivity checks use STUN binding requests:

Controlling Agent Request

// From selection.go:193
msg, err := stun.Build(stun.BindingRequest, stun.TransactionID,
    stun.NewUsername(s.agent.remoteUfrag+":"+s.agent.localUfrag),
    AttrControlling(s.agent.tieBreaker),
    PriorityAttr(local.Priority()),
    stun.NewShortTermIntegrity(s.agent.remotePwd),
    stun.Fingerprint,
)

Nomination Request

// From selection.go:87
msg, err := stun.Build(stun.BindingRequest, stun.TransactionID,
    stun.NewUsername(s.agent.remoteUfrag+":"+s.agent.localUfrag),
    UseCandidate(),  // USE-CANDIDATE attribute
    AttrControlling(s.agent.tieBreaker),
    PriorityAttr(pair.Local.Priority()),
    stun.NewShortTermIntegrity(s.agent.remotePwd),
    stun.Fingerprint,
)

Maximum Binding Requests

Control how many binding requests are sent before considering a pair failed:
agent, err := ice.NewAgentWithOptions(
    ice.WithMaxBindingRequests(7), // default
)
The default is 7 attempts as defined in agent_config.go:48.

Keepalive

Once a pair is selected, keepalive packets maintain the NAT binding:
agent, err := ice.NewAgentWithOptions(
    ice.WithKeepaliveInterval(2 * time.Second), // default
)
Set to 0 to disable keepalives:
agent, err := ice.NewAgentWithOptions(
    ice.WithKeepaliveInterval(0),
)

Renomination

Pion ICE supports renomination as described in draft-thatcher-ice-renomination:
agent, err := ice.NewAgentWithOptions(
    ice.WithRenomination(ice.DefaultNominationValueGenerator()),
)

How Renomination Works

  1. Controlling agent can renominate multiple times
  2. Each nomination includes an incrementing nomination value
  3. Controlled agent follows “last nomination wins” rule
  4. Allows switching to better paths after initial connection

Automatic Renomination

Automatically switch to better candidate pairs:
agent, err := ice.NewAgentWithOptions(
    ice.WithRenomination(ice.DefaultNominationValueGenerator()),
    ice.WithAutomaticRenomination(3 * time.Second),
)
The agent will:
  • Wait at least 3 seconds after connection
  • Continuously evaluate candidate pairs
  • Automatically renominate when a significantly better pair is found (e.g., switching from relay to direct connection)
Automatic renomination requires renomination to be enabled and both agents to support it.

Custom Nomination Logic

Implement custom candidate pair switching with a binding request handler:
handler := func(m *stun.Message, local, remote ice.Candidate, pair *ice.CandidatePair) bool {
    // Custom logic to decide if we should switch to this pair
    if shouldSwitch(pair) {
        log.Printf("Switching to pair: %s <-> %s", local, remote)
        return true // Switch to this pair
    }
    return false // Keep current pair
}

agent, err := ice.NewAgentWithOptions(
    ice.WithBindingRequestHandler(handler),
)

Candidate Pair Priority

Pair priority is calculated based on the controlling role:
// Controlling agent
pairPriority = 2^32 * min(localPriority, remotePriority) + 
               2 * max(localPriority, remotePriority) + 
               (localPriority > remotePriority ? 1 : 0)
Higher priority pairs are checked first.

Connection State Transitions

The ICE connection state transitions through:
  1. New - Agent created
  2. Checking - Performing connectivity checks
  3. Connected - At least one working pair found
  4. Completed - All checks completed (optional)
  5. Failed - All checks failed
  6. Disconnected - Connection lost
  7. Closed - Agent closed
err = agent.OnConnectionStateChange(func(state ice.ConnectionState) {
    switch state {
    case ice.ConnectionStateConnected:
        fmt.Println("ICE connected!")
    case ice.ConnectionStateFailed:
        fmt.Println("ICE failed")
    }
})

Selected Candidate Pair

Monitor when the selected candidate pair changes:
err = agent.OnSelectedCandidatePairChange(func(local, remote ice.Candidate) {
    fmt.Printf("Selected pair: %s <-> %s\n", local, remote)
    fmt.Printf("  Local type: %s\n", local.Type())
    fmt.Printf("  Remote type: %s\n", remote.Type())
})

Candidate Pair Statistics

Retrieve statistics about candidate pairs:
stats := agent.GetCandidatePairsStats()
for _, stat := range stats {
    fmt.Printf("Pair: %s <-> %s\n", stat.LocalCandidateID, stat.RemoteCandidateID)
    fmt.Printf("  State: %s\n", stat.State)
    fmt.Printf("  Nominated: %v\n", stat.Nominated)
    if stat.CurrentRoundTripTime > 0 {
        fmt.Printf("  RTT: %.2fms\n", stat.CurrentRoundTripTime*1000)
    }
}

Lite Agent Behavior

ICE Lite agents have simplified check behavior:
agent, err := ice.NewAgentWithOptions(
    ice.WithICELite(true),
)
  • Do not perform connectivity checks
  • Only provide host candidates
  • Respond to incoming binding requests
  • Accept nominations from full ICE agent
Both agents cannot be ICE Lite. At least one must be a full ICE agent.

Example: Monitoring Connectivity

Here’s a complete example monitoring the connectivity check process:
import (
    "context"
    "fmt"
    "time"
    "github.com/pion/ice/v4"
)

func monitorConnectivity(agent *ice.Agent) {
    // Monitor connection state
    agent.OnConnectionStateChange(func(state ice.ConnectionState) {
        fmt.Printf("[%s] Connection state: %s\n", 
            time.Now().Format("15:04:05"), state)
    })
    
    // Monitor selected pair changes
    agent.OnSelectedCandidatePairChange(func(local, remote ice.Candidate) {
        fmt.Printf("[%s] Selected pair changed\n", time.Now().Format("15:04:05"))
        fmt.Printf("  Local:  %s (type: %s)\n", local, local.Type())
        fmt.Printf("  Remote: %s (type: %s)\n", remote, remote.Type())
        
        // Get RTT for selected pair
        stats := agent.GetCandidatePairsStats()
        for _, stat := range stats {
            if stat.LocalCandidateID == local.ID() && 
               stat.RemoteCandidateID == remote.ID() {
                if stat.CurrentRoundTripTime > 0 {
                    fmt.Printf("  RTT: %.2fms\n", 
                        stat.CurrentRoundTripTime*1000)
                }
            }
        }
    })
    
    // Periodically print statistics
    go func() {
        ticker := time.NewTicker(5 * time.Second)
        defer ticker.Stop()
        
        for range ticker.C {
            stats := agent.GetCandidatePairsStats()
            fmt.Printf("\n=== Candidate Pair Statistics ===\n")
            for _, stat := range stats {
                fmt.Printf("%s <-> %s: %s (nominated: %v)\n",
                    stat.LocalCandidateID,
                    stat.RemoteCandidateID,
                    stat.State,
                    stat.Nominated)
            }
            fmt.Println()
        }
    }()
}

Troubleshooting

Checks Never Succeed

  • Verify remote credentials are correct
  • Check firewall rules allow UDP/TCP traffic
  • Enable debug logging to see detailed check progress
  • Try increasing max binding requests

Connection Takes Too Long

  • Reduce candidate type acceptance wait times
  • Disable unnecessary candidate types
  • Use ICE Lite if appropriate for your deployment

Frequent Disconnections

  • Increase keepalive interval if bandwidth is limited
  • Check for network stability issues
  • Review disconnected/failed timeout settings

Next Steps

Gathering

Learn about candidate gathering

NAT Traversal

Configure NAT traversal strategies

Multiplexing

Share ports with UDPMux and TCPMux

Examples

See connectivity checks in action

Build docs developers (and LLMs) love