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:
Pairing
Local and remote candidates are paired together based on component and foundation
Prioritization
Pairs are sorted by priority (controlling agent uses local + remote priority)
Connectivity Checks
STUN binding requests are sent to test each pair
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:
Waiting - Pair created, waiting to be checked
In Progress - Check in progress
Succeeded - Check succeeded, pair is valid
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
Controlling agent can renominate multiple times
Each nomination includes an incrementing nomination value
Controlled agent follows “last nomination wins” rule
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:
New - Agent created
Checking - Performing connectivity checks
Connected - At least one working pair found
Completed - All checks completed (optional)
Failed - All checks failed
Disconnected - Connection lost
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: %.2f ms \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: %.2f ms \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