The SWIM (Scalable Weakly-consistent Infection-style Process Group Membership) protocol is a gossip-based membership protocol designed for large-scale distributed systems. It provides efficient failure detection and membership dissemination with provable bounds on detection time and message load, making it a cornerstone of modern cluster management.
Traditional heartbeat-based failure detectors suffer from quadratic message complexity as cluster size grows. SWIM solves this by combining randomized probing with infection-style dissemination, achieving O(1) message load per member per protocol period regardless of cluster size. This is what makes it practical for clusters with thousands of nodes.
Systems like HashiCorp’s memberlist library (used in Consul, Nomad, and Serf) implement SWIM with extensions. In large-scale distributed systems, SWIM provides the foundation for peer discovery, health monitoring, and partition-aware routing decisions across distributed search and storage nodes.
k other peers to perform indirect probes on its behalf, routing around potential network asymmetries.SWIM achieves constant message load per member: each node sends and processes a bounded number of messages per protocol period, regardless of cluster size. This is the fundamental advantage over all-to-all heartbeat schemes.
The suspicion mechanism acts as a buffer between detection and declaration: a node transitions through alive → suspect → dead states, giving it a window to refute false suspicions by bumping its incarnation number.
Infection-style dissemination guarantees that a membership update reaches all non-faulty members in O(log n) protocol periods with high probability, bounded by the piggyback limit per message.
SWIM operates in fixed-duration protocol periods (e.g., every 500ms). At the start of each period, the current node selects one random target from its membership list. It sends a direct ping message and waits for an ack. If the ack arrives within a configured timeout, the target is marked alive and the period ends.
If the direct ping times out, the node enters the indirect probing phase. It picks k random members (typically 3) and asks each to send a ping-req to the original target on its behalf. If any of these indirect probes elicit an ack (relayed back through the intermediary), the target is considered alive.
When both direct and indirect probes fail, the node does not immediately declare the target dead. Instead, it marks the target as suspect and starts a suspicion timer. During this window, any message from the suspect node (with a sufficiently high incarnation number) can refute the suspicion and restore the node to alive status.
If the suspicion timer expires without refutation, the node transitions to dead status. This two-phase approach dramatically reduces false positive failure detections in environments with transient network issues, GC pauses, or temporary overload.
Rather than broadcasting membership changes, SWIM piggybacks updates on the protocol messages it is already sending. Each ping, ping-req, and ack message carries a bounded number of recent membership events (sorted by freshness). Each event has a piggyback counter that decrements on each transmission; events are dropped once their counter reaches zero. This provides epidemic spread with bounded bandwidth.
In a distributed search system, SWIM handles membership for indexing and query nodes spread across multiple availability zones. When a shard replica fails, SWIM detects it within 2–3 protocol periods (1–2 seconds), triggering rebalancing of query traffic to surviving replicas.
The indirect probing mechanism is particularly valuable in cloud environments where asymmetric network partitions are common. A node that cannot reach a peer directly may still receive confirmation of liveness through intermediaries, preventing split-brain scenarios where healthy nodes are incorrectly evicted.
SWIM’s piggybacking approach also serves as a lightweight configuration dissemination channel. In many distributed deployments, metadata about shard assignments, leader elections, and schema changes can ride on SWIM protocol messages, reducing the need for separate coordination RPCs.
Rather than selecting targets purely at random, production implementations use a round-robin shuffle. The membership list is shuffled at the start of each epoch, and nodes are probed in order. This ensures every member is probed exactly once per epoch, providing deterministic completeness guarantees rather than probabilistic ones.
The suspicion timeout should be long enough to allow refutation across the expected network RTT plus processing time, but short enough that truly failed nodes are declared dead promptly. A common formula is suspicionTimeout = max(suspicionMult * log(n) * interval, minTimeout), where n is cluster size. This logarithmic scaling ensures the protocol adapts to larger clusters without becoming sluggish in small ones.
To reduce message overhead, SWIM implementations often combine the direct ping and indirect probe phases into a compound message flow. The probing node sends its direct ping and simultaneously pre-selects indirect probe targets. If the direct ping succeeds, the indirect phase is cancelled; if it fails, the indirect probes fire immediately without an additional round-trip delay.
package swim
import (
"math/rand"
"sync"
"time"
)
type NodeState int
const (
Alive NodeState = iota
Suspect
Dead
)
type Member struct {
Addr string
State NodeState
Incarnation uint64
SuspectTime time.Time
}
type SWIMNode struct {
mu sync.RWMutex
self string
members map[string]*Member
incarnation uint64
interval time.Duration
suspTimeout time.Duration
indirectK int
}
func NewSWIMNode(addr string, interval, suspTimeout time.Duration) *SWIMNode {
return &SWIMNode{
self: addr,
members: make(map[string]*Member),
interval: interval,
suspTimeout: suspTimeout,
indirectK: 3,
}
}
func (s *SWIMNode) ProtocolPeriod() {
s.mu.RLock()
target := s.randomMember()
s.mu.RUnlock()
if target == nil {
return
}
// Phase 1: Direct ping
if s.directPing(target.Addr) {
s.markAlive(target.Addr, target.Incarnation)
return
}
// Phase 2: Indirect probes
s.mu.RLock()
intermediaries := s.randomMembers(s.indirectK, target.Addr)
s.mu.RUnlock()
ackCh := make(chan bool, len(intermediaries))
for _, m := range intermediaries {
go func(via string) {
ackCh <- s.indirectPing(via, target.Addr)
}(m.Addr)
}
// Wait for any successful indirect ack
timer := time.NewTimer(s.interval / 2)
defer timer.Stop()
for range intermediaries {
select {
case ack := <-ackCh:
if ack {
s.markAlive(target.Addr, target.Incarnation)
return
}
case <-timer.C:
break
}
}
// Both phases failed: enter suspicion
s.markSuspect(target.Addr)
}
func (s *SWIMNode) markSuspect(addr string) {
s.mu.Lock()
defer s.mu.Unlock()
if m, ok := s.members[addr]; ok && m.State == Alive {
m.State = Suspect
m.SuspectTime = time.Now()
s.enqueueDissemination(addr, Suspect, m.Incarnation)
}
}
func (s *SWIMNode) Refute() {
s.mu.Lock()
defer s.mu.Unlock()
s.incarnation++
s.enqueueDissemination(s.self, Alive, s.incarnation)
}
package swim
import "sort"
type DisseminationEvent struct {
Addr string
State NodeState
Incarnation uint64
Remaining int // piggyback counter
}
type DisseminationQueue struct {
events []DisseminationEvent
limit int // max events per message
}
func NewDisseminationQueue(piggybackLimit int) *DisseminationQueue {
return &DisseminationQueue{
limit: piggybackLimit,
}
}
func (dq *DisseminationQueue) Enqueue(addr string, state NodeState, inc uint64) {
// Replace existing event for same addr if incarnation is higher
for i, e := range dq.events {
if e.Addr == addr {
if inc >= e.Incarnation {
dq.events[i] = DisseminationEvent{
Addr: addr, State: state,
Incarnation: inc, Remaining: dq.piggybackCount(),
}
}
return
}
}
dq.events = append(dq.events, DisseminationEvent{
Addr: addr, State: state,
Incarnation: inc, Remaining: dq.piggybackCount(),
})
}
// GetPiggyback returns up to limit events, sorted by remaining count
// (highest first to prioritize fresh events), and decrements counters.
func (dq *DisseminationQueue) GetPiggyback() []DisseminationEvent {
sort.Slice(dq.events, func(i, j int) bool {
return dq.events[i].Remaining > dq.events[j].Remaining
})
n := dq.limit
if n > len(dq.events) {
n = len(dq.events)
}
result := make([]DisseminationEvent, n)
copy(result, dq.events[:n])
// Decrement counters and prune expired
alive := dq.events[:0]
for i := range dq.events {
if i < n {
dq.events[i].Remaining--
}
if dq.events[i].Remaining > 0 {
alive = append(alive, dq.events[i])
}
}
dq.events = alive
return result
}
func (dq *DisseminationQueue) piggybackCount() int {
// Lambda * log(n+1) where lambda is typically 3
return 3 * (bits(len(dq.events)+1) + 1)
}
func bits(n int) int {
c := 0
for n > 1 {
n >>= 1
c++
}
return c
}
n.