Laravel Reverb WebSocket Presence Channels: Private State Without a Redis Bottleneck
#laravel #reverb #websockets #real-time #redis

Laravel Reverb WebSocket Presence Channels: Private State Without a Redis Bottleneck

4 min read Mohamed Said Mohamed Said

The Real Cost of Presence Channels

Presence channels are the feature that turns a real-time app from useful into delightful — showing who is viewing a document, who is in a chat room, or which agents are online. Laravel Reverb ships with first-class presence support, but naïve implementations quietly hammer your Redis instance on every heartbeat tick, every join, and every leave event.

This article focuses on one concrete problem: keeping presence state consistent and cheap when you have hundreds of concurrent users across multiple Reverb workers.


How Reverb Tracks Presence Internally

Reverb stores channel membership in its configured cache store. By default that is Redis, and the key structure looks roughly like:

reverb:channels:presence-room.42:members

Each member entry is a JSON blob containing the user_id and whatever data you return from join(). When a socket disconnects — cleanly or not — Reverb removes the member. The problem is that every subscribe and unsubscribe triggers a cache read-modify-write cycle, and under high churn (mobile clients toggling background/foreground) this becomes a hot key.


Channel Authentication: Return Only What You Need

The first lever is your BroadcastServiceProvider channel definition. Most tutorials return the entire User model:

// channels/presence-room.php  — the naive version
Broadcast::channel('room.{roomId}', function (User $user, int $roomId) {
    if ($user->canAccessRoom($roomId)) {
        return $user; // serialises every attribute — wasteful
    }
});

Return a minimal array instead. Reverb stores this payload verbatim in its membership hash:

Broadcast::channel('room.{roomId}', function (User $user, int $roomId): array|false {
    if (! $user->canAccessRoom($roomId)) {
        return false;
    }

    return [
        'id'     => $user->id,
        'name'   => $user->display_name,
        'avatar' => $user->avatar_url,
    ];
});

Smaller payloads mean smaller Redis values and faster serialisation under load.


Decoupling Presence State From Redis With a Local Cache Layer

When you run multiple Reverb workers behind a load balancer, each worker needs to agree on membership. The default Redis adapter handles this, but you can reduce round-trips by adding an in-process cache in front:

// AppServiceProvider::boot()
app('cache')->extend('reverb-presence', function ($app) {
    $redis = $app['cache']->driver('redis');

    return Cache::repository(
        new \App\Cache\PresenceCacheStore($redis->getStore())
    );
});
// config/reverb.php
'servers' => [
    'reverb' => [
        // ...
        'options' => [
            'cache_driver' => 'reverb-presence',
        ],
    ],
],

PresenceCacheStore wraps Redis but keeps a short-lived (2–5 second) local array for read operations, flushed on write. This cuts Redis reads by ~80 % for stable rooms while keeping writes consistent.


Handling Dirty Presence: The Stale Member Problem

Network drops leave ghost members. Reverb relies on the WebSocket close event, but mobile clients and proxies swallow it. Add a heartbeat-driven cleanup job:

// Dispatched every 30 seconds via scheduler
class PruneStalePresenceMembers implements ShouldQueue
{
    public function handle(PresenceChannelManager $manager): void
    {
        foreach ($manager->channels() as $channel) {
            $manager->pruneExpiredMembers($channel, olderThan: now()->subMinutes(2));
        }
    }
}

On the client side, send a lightweight ping every 60 seconds that touches a last_seen timestamp stored alongside the member payload. The prune job reads that timestamp and evicts anything stale.


Broadcasting Presence Events Efficiently

Avoid broadcasting the full member list on every join/leave. Instead, broadcast a delta:

class RoomPresenceChanged implements ShouldBroadcast
{
    public function __construct(
        public readonly string $event,   // 'joined' | 'left'
        public readonly array  $member,
        public readonly int    $roomId,
    ) {}

    public function broadcastOn(): PresenceChannel
    {
        return new PresenceChannel("room.{$this->roomId}");
    }

    public function broadcastAs(): string
    {
        return 'presence.changed';
    }
}

The client maintains its own member map and applies deltas, eliminating the need to re-fetch the full list on every change.


Key Takeaways

  • Return minimal arrays from channel auth callbacks — Reverb stores the payload verbatim.
  • A thin local cache layer in front of Redis dramatically reduces hot-key pressure on busy presence channels.
  • Heartbeat-driven prune jobs are essential; do not rely solely on WebSocket close events.
  • Broadcast presence deltas, not full member lists, to keep payload sizes small.
  • Profile your Redis MONITOR output under load before assuming Redis is the bottleneck — often it is the auth endpoint latency.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use a database driver instead of Redis for Reverb presence state?
Reverb's built-in channel manager is designed around a cache store, and Redis is strongly recommended for production because presence operations require atomic read-modify-write semantics. A database driver would introduce locking overhead that negates the performance benefit of WebSockets.
Q02 How do I test presence channel authentication in Pest?
Use `actingAs($user)->postJson('/broadcasting/auth', ['channel_name' => 'presence-room.1', 'socket_id' => '123.456'])` and assert the JSON response contains your expected member payload. Mock `canAccessRoom` on the User model to cover both the allowed and denied paths.
Q03 Does running multiple Reverb workers require any special configuration for presence to stay consistent?
Yes. All workers must share the same Redis instance (or Redis cluster) as their cache store so membership state is centralised. If workers use separate in-memory stores, each worker will have a partial view of who is online.

Continue reading

More Articles

View all