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:
- Client connects and emits
<presence type='available'/>. - Server broadcasts presence to all subscribed entities.
- Client disconnects gracefully with a TCP
FINpacket, 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
FINorRSTpacket. - 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.

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
- Stateful Protocols Need Active Verification: Never rely on passive TCP socket disconnect events for presence in mobile network environments.
- Redis TTL as a Safety Net: Setting atomic key expirations (
EX 45) ensures that silent network drops automatically decay intoofflinestatus without requiring manual cleanup cron jobs. - 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.