← Back to blog
Systems Feb 27, 2026 · 3 min read

What Building an XMPP Bridge Taught Me About Presence

What Building an XMPP Bridge Taught Me About Presence

The Task: Bridging Stateful XMPP to a Stateless REST API

Building user presence ("who is currently online") across heterogeneous client environments is a deceptive engineering challenge.

In our architecture, a legacy core messaging engine operated on XMPP (Extensible Messaging and Presence Protocol) via an ejabberd cluster. Meanwhile, the web dashboards and mobile applications interacted exclusively through a stateless Laravel REST API.

On paper, bridging presence required a simple daemon service to consume XMPP XML <presence/> stanzas, parse state changes, and expose online status endpoints:

<!-- XMPP Presence Stanza Example -->
<presence from='user_9914@chat.internal/mobile' to='bridge.chat.internal'>
    <show>chat</show>
    <status>Available for orders</status>
</presence>

Where Protocol Specifications Disagree with Mobile Reality

XMPP (RFC 6121) assumes an idealized, push-based model over long-lived TCP connections:

  1. Client connects and emits <presence type='available'/>.
  2. Server broadcasts presence to all subscribed entities.
  3. Client disconnects gracefully with a TCP FIN packet, emitting <presence type='unavailable'/>.

In clean laboratory environments with desktop ethernet connections, this model works flawlessly.

graph TD
    Client["Mobile Client"] -->|1. XMPP TCP Session| XMPP["ejabberd XMPP Server"]
    Client -->|2. HTTP Heartbeat every 30s| API["Laravel REST API"]
    XMPP -->|presence XML Stanza| Bridge["XMPP Presence Bridge<br/>Go or PHP Octane Worker"]
    Bridge -->|Publish| Redis["Redis Key-Value Cache<br/>user:id:presence"]
    API -->|Touch Expiry TTL 45s| Redis
    Redis -->|Read Presence State| Frontend["Web Client Dashboard"]

The Silent Drop Problem

Once deployed across mobile cellular networks (4G/5G) and public Wi-Fi, the protocol's assumptions collapsed:

  • Carrier NAT & Cell Tower Hand-offs: When a mobile phone switches cell towers or loses signal in an elevator, the TCP connection drops silently without sending a TCP FIN or RST packet.
  • Aggressive Mobile OS Deep Sleep: Mobile operating systems (iOS and Android) aggressively suspend background socket connections to preserve battery, causing TCP connections to freeze in place.
  • TCP Keep-Alive Latency: The XMPP server's TCP keep-alive probe does not detect a dead connection until the TCP timeout fires — which defaults to anywhere from 5 to 15 minutes.

During this 15-minute window, the XMPP server considers the user "online," displaying stale, inaccurate status indicators to customer support teams.

XMPP REST Presence Bridge Architecture

The Compromise: Dual-Layer Heartbeat with Redis Expiry TTLs

We stopped treating XMPP <presence/> stanzas as ground truth and implemented a dual-signal state engine using Redis key TTLs.

Rather than relying solely on push-based XMPP events, mobile clients emit an explicit HTTP heartbeat every 30 seconds to the REST API:

namespace App\Http\Controllers\Api;

use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Redis;

class PresenceHeartbeatController extends Controller
{
    public function heartbeat(): JsonResponse
    {
        $userId = auth()->id();
        
        // Touch Redis presence key with an aggressive 45-second Expiry TTL
        Redis::set("user:{$userId}:presence", json_encode([
            'status' => 'online',
            'last_seen' => now()->timestamp,
            'source' => 'rest_heartbeat',
        ]), 'EX', 45);

        return response()->json(['status' => 'acknowledged']);
    }
}

Tri-State Presence Machine

By combining XMPP event stanzas with Redis key expirations, we classified presence into three distinct confidence states:

namespace App\Services;

use Illuminate\Support\Facades\Redis;

class PresenceResolver
{
    public function getUserStatus(int $userId): string
    {
        $raw = Redis::get("user:{$userId}:presence");

        if (! $raw) {
            // Redis key expired (No heartbeat received for > 45 seconds)
            return 'offline';
        }

        $data = json_decode($raw, true);
        $age = now()->timestamp - ($data['last_seen'] ?? 0);

        if ($age > 30) {
            // Heartbeat delayed: Connection is degrading
            return 'away';
        }

        return 'online';
    }
}

Key Lessons Learned

  1. Stateful Protocols Need Active Verification: Never rely on passive TCP socket disconnect events for presence in mobile network environments.
  2. Redis TTL as a Safety Net: Setting atomic key expirations (EX 45) ensures that silent network drops automatically decay into offline status without requiring manual cleanup cron jobs.
  3. Decouple Raw Sockets from Application UX: Presenting a "fuzzy" presence indicator (online, away, offline) backed by dual-signal verification provided a vastly better user experience than trusting protocol stanzas unconditionally.