home /
writings /
thoughts /
courses /
research /
projects /
Back to writings

Designing Online/Offline Indicator

Aug 12, 2025 | 8 min read

tl;dr: Building reliable connection state indicators: heartbeat mechanisms, state machines, browser APIs, and handling network transitions gracefully in production systems.

Building a reliable online/offline indicator seems straightforward—until you deploy it to production. Users on flaky networks, mobile devices switching between WiFi and cellular, and servers behind load balancers create edge cases that break naive implementations.

Here’s how to build one that works in the real world.

The Problem Space

Connection state detection isn’t binary. Real networks exist in states between “fully connected” and “completely offline.” Users experience:

  • Intermittent connectivity: WiFi that drops packets but maintains a connection
  • Slow networks: Connections that timeout before responses arrive
  • Partial failures: DNS works but HTTP doesn’t, or vice versa
  • Server-side issues: Your server is up, but the load balancer is down

A good indicator must handle all of these gracefully.

System Architecture

graph TB
    Client[Client Application] -->|HTTP/WebSocket| Network[Network Layer]
    Network -->|Connection| Server[Server]
    Client -->|Heartbeat| Server
    Server -->|ACK| Client
    Client --> BrowserAPI[Browser APIs<br/>navigator.onLine<br/>Network Info API]
    Server --> Monitor[Connection Monitor<br/>Session Store]

Browser APIs: The Foundation

The browser provides navigator.onLine, but it’s unreliable. It only checks if the device has a network interface, not whether it can reach your servers.

// Don't rely solely on this
if (navigator.onLine) {
  // Might still be offline from your server's perspective
}

The Network Information API provides more detail:

const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;

if (connection) {
  console.log(connection.effectiveType); // '4g', '3g', 'slow-2g'
  console.log(connection.downlink); // Mbps
  console.log(connection.rtt); // Round-trip time
}

Use these APIs for fast initial detection, but always verify with your backend.

Heartbeat Mechanism

The most reliable approach is periodic heartbeats—small requests that confirm both network connectivity and server availability.

Heartbeat Flow

sequenceDiagram
    participant C as Client
    participant S as Server
    
    Note over C,S: Normal Operation
    C->>S: Heartbeat (T0)
    S->>C: ACK (T1)
    
    Note over C,S: Timeout Scenario
    C->>S: Heartbeat (T2)
    Note over S: No Response
    Note over C: Timeout (T3)
    Note over C: State: Online → Offline

Implementation Strategy

1. Configurable Intervals

Start with a reasonable default (30 seconds), but make it configurable based on network quality:

class ConnectionMonitor {
  constructor() {
    this.interval = 30000; // 30s default
    this.timeout = 5000; // 5s timeout
    this.failureThreshold = 3; // Mark offline after 3 failures
    this.failures = 0;
  }

  async sendHeartbeat() {
    try {
      const response = await fetch('/api/heartbeat', {
        method: 'HEAD',
        signal: AbortSignal.timeout(this.timeout)
      });
      
      if (response.ok) {
        this.failures = 0;
        return true;
      }
    } catch (error) {
      this.failures++;
      return false;
    }
  }
}

2. Exponential Backoff

When offline, don’t hammer the server. Use exponential backoff for reconnection attempts:

class ReconnectionStrategy {
  constructor() {
    this.baseDelay = 1000; // 1 second
    this.maxDelay = 60000; // 1 minute
    this.currentDelay = this.baseDelay;
  }

  async reconnect() {
    while (!this.isConnected) {
      await this.attemptConnection();
      await this.delay(this.currentDelay);
      this.currentDelay = Math.min(
        this.currentDelay * 2,
        this.maxDelay
      );
    }
    this.currentDelay = this.baseDelay; // Reset on success
  }
}

3. Bidirectional Confirmation

The server should also track client connections. If a client hasn’t sent a heartbeat in 2-3 intervals, mark it as disconnected server-side.

State Machine Design

Connection state isn’t binary. Use a state machine to model transitions:

State Machine

stateDiagram-v2
    [*] --> Online
    Online --> Offline: Connection Lost<br/>Timeout/Error
    Online --> Degraded: Intermittent Failures
    Offline --> Connecting: Reconnect Attempt
    Connecting --> Online: Connection Established
    Connecting --> Offline: Reconnect Failed
    Degraded --> Online: Stability Restored
    Degraded --> Offline: Complete Failure
    Online --> Online: Heartbeat OK
    Offline --> Offline: Still Disconnected

States:

  • Online: Fully connected, receiving heartbeats
  • Offline: No connection, queuing operations
  • Connecting: Actively attempting to reconnect
  • Degraded: Intermittent failures, reduced functionality

Transitions:

class ConnectionStateMachine {
  constructor() {
    this.state = 'online';
    this.listeners = [];
  }

  transition(newState, reason) {
    const validTransitions = {
      online: ['offline', 'degraded'],
      offline: ['connecting'],
      connecting: ['online', 'offline'],
      degraded: ['online', 'offline']
    };

    if (validTransitions[this.state]?.includes(newState)) {
      const oldState = this.state;
      this.state = newState;
      this.notifyListeners(oldState, newState, reason);
    }
  }

  notifyListeners(oldState, newState, reason) {
    this.listeners.forEach(listener => {
      listener({ oldState, newState, reason });
    });
  }
}

Network Detection Strategies

Different strategies have different trade-offs:

Detection Strategies Comparison

Browser API Only

Pros: Fast, no server overhead
Cons: Unreliable, false positives
Use case: Initial optimistic check

Heartbeat Only

Pros: Accurate, server status confirmed
Cons: Latency delay, server overhead
Use case: Critical applications

Hybrid Approach (Recommended)

Pros: Fast initial detection + accurate verification
Cons: More complex state management
Use case: Production applications

Recommended Implementation Flow

flowchart LR
    A[Check navigator.onLine] -->|Fast Check| B{Online?}
    B -->|No| C[Immediate Offline State]
    B -->|Yes| D[Send Heartbeat]
    D --> E{Response?}
    E -->|Success| F[Online State]
    E -->|Timeout| G[Mark Offline]
    E -->|Intermittent| H[Degraded State]

Recommended Flow

  1. Check navigator.onLine - Fast initial check
  2. Send heartbeat - Verify server connectivity
  3. Wait for response - With timeout
  4. Update state - Based on result, notify UI
class HybridConnectionMonitor {
  async checkConnection() {
    // Fast check first
    if (!navigator.onLine) {
      this.transition('offline', 'browser-api');
      return;
    }

    // Verify with heartbeat
    const isConnected = await this.sendHeartbeat();
    
    if (isConnected) {
      this.transition('online', 'heartbeat-success');
    } else {
      this.handleFailure();
    }
  }

  handleFailure() {
    this.failures++;
    
    if (this.failures >= this.failureThreshold) {
      this.transition('offline', 'heartbeat-timeout');
    } else if (this.failures >= 2) {
      this.transition('degraded', 'intermittent-failures');
    }
  }
}

Handling Edge Cases

Flaky Connections

Networks that work sometimes need special handling:

class FlakyConnectionHandler {
  constructor() {
    this.recentFailures = [];
    this.windowSize = 10; // Track last 10 attempts
  }

  recordAttempt(success) {
    this.recentFailures.push({
      success,
      timestamp: Date.now()
    });

    // Keep only recent attempts
    if (this.recentFailures.length > this.windowSize) {
      this.recentFailures.shift();
    }

    const failureRate = this.calculateFailureRate();
    
    if (failureRate > 0.5) {
      return 'degraded';
    } else if (failureRate === 1.0) {
      return 'offline';
    }
    return 'online';
  }
}

Timeout Configuration

Different operations need different timeouts:

  • Heartbeat: 3-5 seconds (fast failure)
  • API calls: 10-30 seconds (user-initiated)
  • File uploads: 60+ seconds (long operations)
const timeouts = {
  heartbeat: 5000,
  api: 10000,
  upload: 60000
};

// Use appropriate timeout per operation
const response = await fetch(url, {
  signal: AbortSignal.timeout(timeouts.api)
});

Server-Side Considerations

Your server needs to handle connection state too:

  1. Session tracking: Track last heartbeat per client
  2. Cleanup: Remove stale sessions after timeout
  3. Load balancing: Ensure heartbeats hit the same server (sticky sessions) or use shared state
# Example server-side tracking
class ConnectionTracker:
    def __init__(self):
        self.connections = {}  # client_id -> last_heartbeat
    
    def record_heartbeat(self, client_id):
        self.connections[client_id] = time.time()
    
    def get_online_clients(self, timeout=90):
        now = time.time()
        return [
            client_id for client_id, last_seen in self.connections.items()
            if now - last_seen < timeout
        ]

UI/UX Considerations

How you present connection state matters:

Visual Indicators:

  • Green dot: Online
  • Yellow dot: Degraded/Connecting
  • Red dot: Offline
  • Pulsing animation: Actively checking

User Actions:

  • Online: Full functionality
  • Degraded: Show warning, allow retry
  • Offline: Queue operations, show “will sync when online”

Notifications:

  • Don’t spam users with every state change
  • Show notifications only on significant transitions (online → offline, offline → online)
  • Use toast notifications, not blocking modals
class ConnectionUI {
  updateIndicator(state) {
    const indicator = document.getElementById('connection-indicator');
    
    const states = {
      online: { color: 'green', text: 'Online' },
      offline: { color: 'red', text: 'Offline' },
      degraded: { color: 'yellow', text: 'Unstable' },
      connecting: { color: 'blue', text: 'Connecting...' }
    };

    const config = states[state];
    indicator.className = `connection-${config.color}`;
    indicator.textContent = config.text;
  }

  showNotification(oldState, newState) {
    // Only notify on significant transitions
    const significant = [
      ['online', 'offline'],
      ['offline', 'online']
    ];

    if (significant.some(([old, new]) => old === oldState && new === newState)) {
      this.toast(`${oldState} → ${newState}`);
    }
  }
}

Performance Considerations

Connection monitoring has costs:

Client-side:

  • Battery drain (mobile devices)
  • Network usage (heartbeat requests)
  • CPU usage (state management)

Server-side:

  • Request overhead (heartbeat endpoints)
  • Memory (connection tracking)
  • Database writes (if persisting state)

Optimizations:

  1. Adaptive intervals: Increase heartbeat interval when stable, decrease when unstable
  2. Batch operations: Queue operations when offline, send in batch when online
  3. Smart polling: Only poll when tab is active (Page Visibility API)
// Adaptive heartbeat interval
class AdaptiveHeartbeat {
  constructor() {
    this.baseInterval = 30000;
    this.currentInterval = this.baseInterval;
    this.stableCount = 0;
  }

  onHeartbeatSuccess() {
    this.stableCount++;
    
    // Increase interval if stable for 5+ cycles
    if (this.stableCount >= 5) {
      this.currentInterval = Math.min(
        this.currentInterval * 1.5,
        300000 // Max 5 minutes
      );
    }
  }

  onHeartbeatFailure() {
    this.stableCount = 0;
    this.currentInterval = this.baseInterval; // Reset to base
  }
}

Testing Strategies

Test your implementation under various conditions:

  1. Network throttling: Chrome DevTools → Network → Throttling
  2. Offline simulation: navigator.onLine = false
  3. Server failures: Kill your server, restart it
  4. Timeout scenarios: Set very short timeouts
  5. Rapid state changes: Toggle network on/off quickly
// Test helper
class ConnectionTester {
  async testOfflineScenario() {
    // Simulate offline
    navigator.onLine = false;
    
    // Wait for state transition
    await this.waitForState('offline', 5000);
    
    // Verify UI updates
    assert.equal(this.getIndicatorState(), 'offline');
    
    // Simulate online
    navigator.onLine = true;
    await this.monitor.sendHeartbeat();
    
    // Verify recovery
    await this.waitForState('online', 10000);
  }
}

Key Takeaways

  1. Never trust navigator.onLine alone - Always verify with your backend
  2. Use state machines - Connection state has more than two values
  3. Implement exponential backoff - Don’t hammer servers when reconnecting
  4. Handle flaky connections - Degraded state is as important as offline
  5. Optimize for battery - Adaptive intervals, Page Visibility API
  6. Test edge cases - Network conditions vary wildly in production

Building a reliable online/offline indicator requires thinking beyond simple boolean checks. It’s a distributed systems problem that needs careful state management, timeout handling, and graceful degradation.

The best implementations combine browser APIs for fast detection with heartbeat mechanisms for accuracy, wrapped in a state machine that handles the complexity of real-world networks.

Building reliable connection monitoring? I provide architecture reviews, system design consultation, and engineering guidance. Let's discuss your implementation.

P.S. Follow me on Twitter where I share engineering insights, system design patterns, and technical leadership perspectives.

Enjoyed this? Support my work

Buy me a coffee