Skip to main content
ICE candidates represent potential network endpoints for establishing peer-to-peer connections. Each candidate describes a transport address (IP and port) that can be used for communication.

What is a Candidate?

A candidate is defined by the Candidate interface in candidate.go:22:
type Candidate interface {
    Foundation() string
    ID() string
    Component() uint16
    
    NetworkType() NetworkType
    Address() string
    Port() int
    Priority() uint32
    
    Type() CandidateType
    TCPType() TCPType
    
    RelatedAddress() *CandidateRelatedAddress
    LastReceived() time.Time
    LastSent() time.Time
    
    // Extension attributes (RFC 5245)
    Extensions() []CandidateExtension
    GetExtension(key string) (CandidateExtension, bool)
    AddExtension(CandidateExtension) error
}

Candidate Types

ICE defines four candidate types (from candidatetype.go:12-18):
const (
    CandidateTypeUnspecified CandidateType = iota
    CandidateTypeHost
    CandidateTypeServerReflexive
    CandidateTypePeerReflexive
    CandidateTypeRelay
)

Host Candidates

Host candidates represent direct addresses from the local machine’s network interfaces.
Host candidates are the most efficient connection type as they enable direct peer-to-peer communication without intermediary servers.
Characteristics:
  • Gathered from local network interfaces (WiFi, Ethernet, VPN, etc.)
  • Highest priority (126) for preference
  • No additional infrastructure required
  • May be private IPs (192.168.x.x, 10.x.x.x) behind NAT
Creation:
candidate, err := ice.NewCandidateHost(&ice.CandidateHostConfig{
    Network:   "udp",
    Address:   "192.168.1.100",
    Port:      54321,
    Component: ice.ComponentRTP,
})
Implementation: candidate_host.go:32-70

Server Reflexive Candidates

Server reflexive (srflx) candidates represent the public IP address and port as seen by a STUN server. Characteristics:
  • Discovered by sending STUN requests to a STUN server
  • Shows how the local endpoint appears from outside the NAT
  • Priority: 100
  • Includes related address (the local host candidate that was used)
How it works:
  1. Agent sends STUN Binding Request to STUN server
  2. STUN server responds with XOR-MAPPED-ADDRESS
  3. This address becomes a server reflexive candidate
candidate, err := ice.NewCandidateServerReflexive(&ice.CandidateServerReflexiveConfig{
    Network:   "udp",
    Address:   "203.0.113.42",  // Public IP from STUN
    Port:      54321,
    Component: ice.ComponentRTP,
    RelAddr:   "192.168.1.100",  // Local IP that was mapped
    RelPort:   54321,
})
Implementation: candidate_server_reflexive.go:30-68 Gathering: gather.go:735-824

Peer Reflexive Candidates

Peer reflexive (prflx) candidates are discovered during connectivity checks, not during gathering. Characteristics:
  • Learned when receiving binding requests from unexpected addresses
  • Priority: 110 (higher than server reflexive!)
  • Occurs when peers are behind symmetric NATs
  • Automatically created by the agent
When created: From agent.go:1562-1589, when an inbound binding request arrives from an unknown address:
if remoteCandidate == nil {
    // Create peer reflexive candidate
    prflxCandidate, err := ice.NewCandidatePeerReflexive(&ice.CandidatePeerReflexiveConfig{
        Network:   networkType.String(),
        Address:   ip.String(),
        Port:      port,
        Component: local.Component(),
    })
    
    a.addRemoteCandidate(prflxCandidate)
}
Implementation: candidate_peer_reflexive.go:32-66
Peer reflexive candidates have higher priority than server reflexive because they represent the actual path being used for connectivity checks, which may differ from the path through the STUN server.

Relay Candidates

Relay candidates represent addresses allocated on TURN (Traversal Using Relays around NAT) servers. Characteristics:
  • Obtained by allocating a relay address on a TURN server
  • Priority: 0 (lowest - used as last resort)
  • All media flows through the relay server
  • Most reliable but highest latency and server cost
  • Supports UDP, TCP, TLS, and DTLS transport
Relay protocols (from candidate_relay.go:11-18):
const (
    preferenceRelayTLS  = 0  // turn:server:port?transport=tcp over TLS
    preferenceRelayTCP  = 1  // turn:server:port?transport=tcp
    preferenceRelayDTLS = 2  // turns:server:port (DTLS over UDP)
    preferenceRelayUDP  = 3  // turn:server:port?transport=udp
)
Creation:
candidate, err := ice.NewCandidateRelay(&ice.CandidateRelayConfig{
    Network:       "udp",
    Address:       "198.51.100.1",  // TURN relay address
    Port:          60000,
    Component:     ice.ComponentRTP,
    RelAddr:       "192.168.1.100", // Local address
    RelPort:       54321,
    RelayProtocol: "udp",
})
Implementation: candidate_relay.go:45-130 Gathering: gather.go:827-1047

Candidate Priority

Type Preferences

From candidatetype.go:44-57, RFC 5245 recommended preferences:
func (c CandidateType) Preference() uint16 {
    switch c {
    case CandidateTypeHost:
        return 126  // Highest - direct connection
    case CandidateTypePeerReflexive:
        return 110
    case CandidateTypeServerReflexive:
        return 100
    case CandidateTypeRelay:
        return 0    // Lowest - relayed connection
    }
}

Priority Calculation

The full priority formula combines:
  • Type preference (126, 110, 100, or 0)
  • Local preference (65535 by default)
  • Component ID (1 for RTP, 2 for RTCP)
priority = (2^24)*(type preference) + 
           (2^8)*(local preference) + 
           (2^0)*(256 - component ID)
This means:
  • Host candidates are always preferred over reflexive
  • Reflexive are preferred over relay
  • Within the same type, local preference distinguishes interfaces
  • RTP components are slightly preferred over RTCP

Candidate Pair Priority

When forming pairs, the priority is calculated from candidatepair.go:92-129:
// RFC 5245 - 5.7.2
// pair priority = 2^32*MIN(G,D) + 2*MAX(G,D) + (G>D?1:0)
// G = controlling agent's candidate priority
// D = controlled agent's candidate priority

func (p *CandidatePair) priority() uint64 {
    var g, d uint32
    if p.iceRoleControlling {
        g = p.Local.Priority()
        d = p.Remote.Priority()
    } else {
        g = p.Remote.Priority()
        d = p.Local.Priority()
    }
    
    return (1<<32-1)*min(g, d) + 2*max(g, d) + cmp(g, d)
}

Candidate Gathering

The Gathering Process

Gathering is initiated with GatherCandidates() from gather.go:51-77:
err := agent.GatherCandidates()

// Candidates are delivered via the OnCandidate callback:
agent.OnCandidate(func(c ice.Candidate) {
    if c == nil {
        // Gathering complete
    } else {
        // New candidate available
        sendToRemotePeer(c.Marshal())
    }
})

Gathering Stages

Implemented in gather.go:196-220:
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()
}
From gather.go:245-428, host gathering:
  1. Enumerate network interfaces using the configured filters
  2. For each interface address:
    • Apply IP filters (skip loopback unless configured)
    • Apply interface filters (skip Docker, VMs, etc.)
    • Apply location tracking filters (skip IPv6 link-local)
    • Create UDP or TCP socket on available port
  3. Apply NAT 1:1 mappings if configured
  4. Create CandidateHost for each valid address
  5. Add to local candidates and emit via OnCandidate
Special handling for mDNS:
if a.mDNSMode == MulticastDNSModeQueryAndGather {
    address = a.mDNSName // Use .local address instead of IP
}
From gather.go:735-824, srflx gathering:
  1. For each STUN URL:
    • Open UDP socket
    • Send STUN Binding Request
    • Wait for response with timeout (default 5s)
    • Extract XOR-MAPPED-ADDRESS
  2. Create CandidateServerReflexive
  3. Associate with local candidate as related address
  4. Add to local candidates and emit
Parallel requests:
for _, url := range urls {
    wg.Add(1)
    go func(url stun.URI) {
        defer wg.Done()
        xorAddr, err := stunx.GetXORMappedAddr(conn, serverAddr, timeout)
        // Create candidate from xorAddr
    }(url)
}
From gather.go:827-1047, relay gathering:
  1. For each TURN URL:
    • Connect to TURN server (UDP, TCP, TLS, or DTLS)
    • Authenticate with username/password
    • Send TURN Allocate request
    • Receive allocated relay address
  2. Create CandidateRelay
  3. Maintain TURN client for the allocation lifetime
  4. Add to local candidates and emit
Protocol matrix:
switch {
case url.Proto == ProtoTypeUDP && url.Scheme == SchemeTypeTURN:
    // UDP TURN
case url.Proto == ProtoTypeTCP && url.Scheme == SchemeTypeTURN:
    // TCP TURN
case url.Proto == ProtoTypeUDP && url.Scheme == SchemeTypeTURNS:
    // DTLS TURN
case url.Proto == ProtoTypeTCP && url.Scheme == SchemeTypeTURNS:
    // TLS TURN
}

Continual Gathering

Pion ICE supports continual gathering (monitoring for network changes):
agent, err := ice.NewAgentWithOptions(
    ice.WithContinualGatheringPolicy(ice.GatherContinually),
)
From gather.go:89-112, continual gathering:
  1. Performs initial gathering
  2. Monitors network interfaces every 2 seconds (configurable)
  3. Detects new IP addresses
  4. Automatically gathers candidates for new interfaces
  5. Continues until agent is closed

Candidate Selection

Forming Candidate Pairs

The agent forms pairs from local and remote candidates (from agent.go:860-868):
func (a *Agent) addPair(local, remote Candidate) *CandidatePair {
    a.nextPairID++
    p := newCandidatePair(local, remote, a.isControlling.Load())
    p.id = a.nextPairID
    a.checklist = append(a.checklist, p)
    a.pairsByID[p.id] = p
    return p
}

The Checklist

Pairs go through states (from candidatepair_state.go:9-26):
const (
    CandidatePairStateWaiting      // Check not performed
    CandidatePairStateInProgress   // Check sent, waiting for response
    CandidatePairStateFailed       // Check failed or timed out
    CandidatePairStateSucceeded    // Check succeeded
)

Selection Algorithm

From agent.go:827-858, finding the best valid pair:
func (a *Agent) getBestValidCandidatePair() *CandidatePair {
    var best *CandidatePair
    for _, p := range a.checklist {
        if p.state != CandidatePairStateSucceeded {
            continue
        }
        
        if best == nil {
            best = p
        } else if best.priority() < p.priority() {
            best = p  // Higher priority wins
        }
    }
    return best
}

Nomination

The controlling agent nominates the selected pair:
// Controlling agent sends binding request with USE-CANDIDATE
msg, _ := stun.Build(
    stun.BindingRequest,
    stun.NewUsername(remoteUfrag + ":" + localUfrag),
    UseCandidate(),  // This flag nominates the pair
    AttrControlling(tieBreaker),
    stun.NewShortTermIntegrity(remotePwd),
    stun.Fingerprint,
)

Candidate String Format

Candidates are serialized following RFC 5245:
candidate:<foundation> <component> <protocol> <priority> <address> <port> typ <type> [raddr <rel-addr>] [rport <rel-port>]
Examples:
# Host candidate
candidate:1 1 UDP 2130706431 192.168.1.100 54321 typ host

# Server reflexive
candidate:2 1 UDP 1694498815 203.0.113.42 54321 typ srflx raddr 192.168.1.100 rport 54321

# Relay candidate
candidate:3 1 UDP 16777215 198.51.100.1 60000 typ relay raddr 192.168.1.100 rport 54321

Best Practices

Always gather multiple candidate types for the best chance of connectivity. Even if you have a public IP, gathering relay candidates provides a fallback for restrictive networks.
Filter candidates carefully when behind corporate firewalls. Some organizations block UDP or restrict ports, making TCP or relay candidates necessary.
To reduce gathering time:
agent, err := ice.NewAgentWithOptions(
    // Reduce STUN timeout
    ice.WithSTUNGatherTimeout(3 * time.Second),
    
    // Only gather needed types
    ice.WithCandidateTypes([]ice.CandidateType{
        ice.CandidateTypeHost,
        ice.CandidateTypeServerReflexive,
    }),
    
    // Limit network types
    ice.WithNetworkTypes([]ice.NetworkType{
        ice.NetworkTypeUDP4,
    }),
)
Be careful not to sacrifice connectivity for speed.
Symmetric NATs allocate different mappings for different destinations. This makes server reflexive candidates ineffective.Indicators:
  • STUN server shows one port
  • Remote peer sees different port
  • Connection only works with relay
Solution: Peer reflexive candidates (automatically created) or relay candidates.
  • ICE Protocol - Understanding the overall ICE process
  • Agents - Managing the ICE agent lifecycle
  • Connectivity - How candidates are tested and selected

Build docs developers (and LLMs) love