Building Real-Time Applications with WebSockets

Reverend Philip Nov 29, 2025 11 min read

Add real-time features to your applications. Understand WebSockets, Socket.io, and Laravel Broadcasting for live updates.

Real-time features transform static applications into dynamic, engaging experiences. From live notifications to collaborative editing, WebSockets enable instant bidirectional communication between clients and servers. This guide covers WebSocket fundamentals and implementation patterns.

Why WebSockets?

The Problem with HTTP

HTTP is request-response based. For real-time updates, you'd need:

Polling: Regular requests to check for updates

With polling, your client repeatedly asks "anything new?" most of the time the answer is no, wasting bandwidth and server resources. The code below shows this naive approach that should be avoided for real-time features.

// Wasteful - most requests return nothing new
setInterval(() => fetch('/api/messages'), 5000);

This approach creates unnecessary load on your server and drains mobile batteries. Users also experience a delay of up to the polling interval before seeing new data.

Long Polling: Hold connection open until data available

Long polling improves on regular polling by keeping the connection open until the server has something to send. But each response requires a new connection, as shown in this example.

// Better, but reconnection overhead
async function longPoll() {
    const response = await fetch('/api/messages/wait');
    processMessages(response);
    longPoll(); // Reconnect
}

This reduces unnecessary requests but still incurs connection overhead for each message. Each reconnection requires a full TCP handshake and potentially TLS negotiation.

WebSocket Advantages

  • Full duplex: Both client and server can send anytime
  • Low latency: No connection overhead per message
  • Efficient: Binary framing, minimal headers
  • Persistent: Single connection for entire session

WebSocket Fundamentals

The Handshake

WebSocket starts as HTTP, then upgrades:

The initial connection begins as a standard HTTP request with special headers indicating the client wants to upgrade to WebSocket protocol. The server responds with a 101 status confirming the protocol switch. This handshake ensures WebSocket traffic can traverse proxies and firewalls that expect HTTP.

Client: GET /socket HTTP/1.1
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

Server: HTTP/1.1 101 Switching Protocols
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

After handshake, both sides can send frames anytime. The Sec-WebSocket-Key and Sec-WebSocket-Accept headers provide a basic security check to prevent cross-protocol attacks.

Browser API

The browser's native WebSocket API is straightforward. You create a connection, then attach event handlers for the connection lifecycle and incoming messages. Here's the complete pattern for establishing and managing a WebSocket connection.

const socket = new WebSocket('wss://example.com/socket');

socket.addEventListener('open', (event) => {
    console.log('Connected');
    socket.send(JSON.stringify({ type: 'subscribe', channel: 'updates' }));
});

socket.addEventListener('message', (event) => {
    const data = JSON.parse(event.data);
    console.log('Received:', data);
});

socket.addEventListener('close', (event) => {
    console.log('Disconnected:', event.code, event.reason);
});

socket.addEventListener('error', (error) => {
    console.error('WebSocket error:', error);
});

Note the wss:// protocol - always use secure WebSockets in production, just as you'd use HTTPS for regular traffic. The close event includes a code and reason that can help you diagnose disconnection causes, which is valuable for debugging connection issues.

Laravel Broadcasting

Laravel provides an elegant abstraction over WebSockets.

Setup

Laravel's Reverb is a first-party WebSocket server that integrates seamlessly with Laravel's event system. Installation configures everything you need. Run the following command to get started.

# Install Reverb (Laravel's WebSocket server)
php artisan install:broadcasting

After installation, configure your environment variables with the credentials provided during setup.

BROADCAST_DRIVER=reverb
REVERB_APP_ID=your-app-id
REVERB_APP_KEY=your-app-key
REVERB_APP_SECRET=your-app-secret

These credentials ensure only your application can broadcast to your WebSocket server. Keep the secret secure and never expose it to client-side code.

Defining Events

Broadcast events implement ShouldBroadcast and define which channels to broadcast to. The broadcastWith method lets you control exactly what data gets sent over the wire. Here's a complete example of a broadcast event class.

// app/Events/MessageSent.php
class MessageSent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public Message $message
    ) {}

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('conversation.' . $this->message->conversation_id),
        ];
    }

    public function broadcastWith(): array
    {
        return [
            'id' => $this->message->id,
            'content' => $this->message->content,
            'user' => $this->message->user->only(['id', 'name', 'avatar']),
            'created_at' => $this->message->created_at->toIso8601String(),
        ];
    }
}

The broadcastWith method is important - without it, Laravel serializes the entire model and its relationships, potentially exposing data you didn't intend to share. By explicitly defining the payload, you control exactly what clients receive and avoid accidentally leaking sensitive fields.

Broadcasting Events

Dispatching a broadcast event works like any other Laravel event. The framework handles serialization and delivery to connected clients. You have two options for dispatching.

// Dispatch the event
MessageSent::dispatch($message);

// Or broadcast directly without event class
broadcast(new MessageSent($message));

Both approaches work identically. The broadcast() helper is slightly more explicit about the intent, which some developers prefer for readability.

Channel Authorization

Private channels require authentication. Laravel checks the authorization callback before allowing a client to subscribe. Define your channel authorization rules in the channels file.

// routes/channels.php
Broadcast::channel('conversation.{conversationId}', function ($user, $conversationId) {
    return $user->conversations->contains($conversationId);
});

// Presence channels (shows who's online)
Broadcast::channel('project.{projectId}', function ($user, $projectId) {
    if ($user->projects->contains($projectId)) {
        return ['id' => $user->id, 'name' => $user->name];
    }
});

Presence channels are special - the returned array becomes the user's presence data, visible to all other channel members. Return false or null to deny access. This is how features like "who's viewing this document" get implemented.

Frontend with Laravel Echo

Laravel Echo provides a JavaScript API that mirrors the backend channel concepts. It handles authentication, reconnection, and channel management automatically. Here's how to configure Echo and subscribe to channels.

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT,
    forceTLS: false,
    enabledTransports: ['ws', 'wss'],
});

// Private channel
Echo.private(`conversation.${conversationId}`)
    .listen('MessageSent', (e) => {
        addMessage(e.message);
    });

// Presence channel
Echo.join(`project.${projectId}`)
    .here((users) => {
        setOnlineUsers(users);
    })
    .joining((user) => {
        addOnlineUser(user);
    })
    .leaving((user) => {
        removeOnlineUser(user);
    });

Presence channels provide three callbacks: here fires once on connect with all current members, joining fires when someone new arrives, and leaving fires when someone disconnects. This gives you complete visibility into channel membership for building collaborative features.

Common Real-Time Patterns

Live Notifications

The most common real-time feature is notifications. Each user subscribes to their private channel, and you broadcast whenever something they should know about happens. Here's a minimal notification event.

// Event
class NotificationCreated implements ShouldBroadcast
{
    public function __construct(public Notification $notification) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('user.' . $this->notification->user_id)];
    }
}

// When notification is created
NotificationCreated::dispatch($notification);

On the frontend, subscribe to the user's channel and handle incoming notifications appropriately.

Echo.private(`user.${userId}`)
    .listen('NotificationCreated', (e) => {
        showNotification(e.notification);
        incrementBadge();
    });

This pattern ensures users see notifications instantly without polling, creating a responsive experience similar to native mobile apps.

Live Activity Feed

Team activity feeds let everyone see what's happening in real-time. Broadcast to the team channel whenever someone performs a noteworthy action. This creates a shared awareness of team activity.

class ActivityLogged implements ShouldBroadcast
{
    public function __construct(
        public Activity $activity,
        public int $teamId
    ) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('team.' . $this->teamId)];
    }
}

You can filter or aggregate activities on the client side to avoid overwhelming users with too many updates during busy periods.

Real-Time Collaboration

For collaborative features like shared editing:

Presence channels shine for collaboration - you can see who else is viewing the document and broadcast their cursor positions or selections. Here's how you might broadcast cursor movements.

// Broadcast cursor positions
class CursorMoved implements ShouldBroadcast
{
    public function __construct(
        public int $documentId,
        public int $userId,
        public array $position
    ) {}

    public function broadcastOn(): array
    {
        return [new PresenceChannel('document.' . $this->documentId)];
    }
}

For true collaborative editing, consider specialized libraries like Yjs or Automerge that handle conflict resolution. WebSockets provide the transport layer, but operational transformation or CRDTs handle the complexity of concurrent edits.

Progress Updates

Long-running operations benefit from progress broadcasting. Users see a real-time progress bar instead of staring at a spinner. Here's a complete implementation for an export job with progress updates.

class ExportProgress implements ShouldBroadcast
{
    public function __construct(
        public string $exportId,
        public int $progress,
        public ?string $downloadUrl = null
    ) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('export.' . $this->exportId)];
    }
}

// In export job
foreach ($items->chunk(100) as $index => $chunk) {
    $this->processChunk($chunk);

    ExportProgress::dispatch(
        $this->exportId,
        ($index + 1) / $totalChunks * 100
    );
}

ExportProgress::dispatch($this->exportId, 100, $downloadUrl);

The final broadcast includes the download URL, signaling completion. The frontend can then show a download button instead of a progress bar. Be mindful of broadcast frequency - updating every 100 items is usually sufficient without overwhelming the WebSocket connection.

Scaling WebSockets

The Challenge

WebSocket connections are stateful. When you have multiple servers, clients might connect to different servers than where events originate.

Solutions

Redis Pub/Sub Backend:

Redis pub/sub solves the multi-server problem. When any server broadcasts an event, Redis distributes it to all subscribed servers, which then push to their connected clients. Configure this in your broadcasting config.

// config/broadcasting.php
'reverb' => [
    'driver' => 'reverb',
    'redis' => [
        'connection' => 'default',
    ],
],

All servers subscribe to Redis. When one server broadcasts, all servers receive and push to their connected clients. This architecture scales horizontally - add more Reverb instances as your connection count grows.

Sticky Sessions: Load balancer routes same client to same server. This is simpler but limits your scaling flexibility since you can't freely redistribute connections.

Horizontal Scaling with Reverb:

You can run multiple Reverb instances behind a load balancer. With Redis as the backend, they'll share state and any instance can broadcast to any connected client. Here's the basic command to start a Reverb server.

# Run multiple Reverb instances behind load balancer
php artisan reverb:start --host=0.0.0.0 --port=8080

Deploy this behind a load balancer that supports WebSocket connections, ensuring it's configured for long-lived connections rather than standard HTTP timeout settings.

Error Handling and Reconnection

Connections drop - networks are unreliable. A production WebSocket client needs automatic reconnection with exponential backoff to avoid overwhelming your server during outages. Here's a robust implementation pattern.

class WebSocketClient {
    constructor(url) {
        this.url = url;
        this.reconnectAttempts = 0;
        this.maxReconnectAttempts = 10;
        this.connect();
    }

    connect() {
        this.socket = new WebSocket(this.url);

        this.socket.onopen = () => {
            this.reconnectAttempts = 0;
            this.resubscribe();
        };

        this.socket.onclose = (event) => {
            if (!event.wasClean) {
                this.reconnect();
            }
        };

        this.socket.onerror = () => {
            this.socket.close();
        };
    }

    reconnect() {
        if (this.reconnectAttempts < this.maxReconnectAttempts) {
            const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
            this.reconnectAttempts++;

            setTimeout(() => this.connect(), delay);
        }
    }
}

The exponential backoff (Math.pow(2, attempts)) starts at 1 second and doubles each attempt, capping at 30 seconds. This prevents thundering herd problems when your server recovers from an outage. Note the resubscribe() call on successful connection - you need to re-establish any channel subscriptions after reconnecting.

Security Considerations

Authentication

Always authenticate WebSocket connections:

Never trust that a client should have access to a channel just because they know its name. Verify authorization on every subscription. Laravel's channel authorization makes this straightforward.

// channels.php
Broadcast::channel('user.{userId}', function ($user, $userId) {
    return (int) $user->id === (int) $userId;
});

The type casting ensures string route parameters match integer user IDs correctly. This prevents subtle bugs where "123" might not equal 123.

Input Validation

Validate all incoming WebSocket messages:

Just like HTTP requests, WebSocket messages can contain malicious data. Parse and validate before processing. Never trust client-provided data.

socket.onmessage = (event) => {
    try {
        const data = JSON.parse(event.data);
        if (!isValidMessage(data)) {
            return;
        }
        processMessage(data);
    } catch (e) {
        console.error('Invalid message:', e);
    }
};

Your isValidMessage function should check for required fields, validate types, and enforce any business rules before processing.

Rate Limiting

Prevent message flooding:

Without rate limiting, a malicious client could flood your server with messages. Track message counts per connection and disconnect abusers. Here's how you might implement this server-side.

// In your WebSocket handler
if ($this->rateLimiter->tooManyAttempts($connectionId, 100)) {
    $this->disconnect($connectionId, 'Rate limit exceeded');
    return;
}
$this->rateLimiter->hit($connectionId);

Consider different limits for different message types - chat messages might have tighter limits than cursor position updates.

Testing

Testing broadcast events is straightforward with Laravel's event faking. You can verify events are dispatched with the correct data without actually connecting to WebSocket infrastructure. Here's a typical test pattern.

// Feature test
public function test_message_broadcasts_to_channel()
{
    Event::fake();

    $message = Message::factory()->create();

    MessageSent::dispatch($message);

    Event::assertDispatched(MessageSent::class, function ($event) use ($message) {
        return $event->message->id === $message->id;
    });
}

For end-to-end testing of WebSocket connections, consider tools like Playwright that can interact with WebSocket-enabled pages. You can also test your channel authorization logic by directly calling the authorization callbacks with mock users.

Conclusion

WebSockets enable powerful real-time features that enhance user experience. Laravel's broadcasting system provides an elegant abstraction that handles authentication, channel management, and scaling concerns. Start with simple notifications, then expand to more complex collaborative features as needed.

Share this article

Related Articles

Distributed Locking Patterns

Coordinate access to shared resources across services. Implement distributed locks with Redis, ZooKeeper, and databases.

Jan 16, 2026

API Design First Development

Design APIs before implementing them. Use OpenAPI specifications, mock servers, and contract-first workflows.

Jan 15, 2026

Need help with your project?

Let's discuss how we can help you build reliable software.