Technology

WebSocket Hosting: Laravel Reverb and Socket.IO

Chat, live dashboards and in-app notifications need timely updates, but they do not all require two-way communication. WebSocket hosting becomes relevant when the application needs persistent bidirectional messaging. With Laravel Echo, Reverb or Socket.IO, the practical question is whether the chosen shared plan supports the workload or whether a VPS or managed realtime service is a better fit.

This article explains how WebSockets work, which limitations you may hit on typical shared hosting, how a VPS changes the picture, and concrete architectures for Node.js, Laravel Echo and Socket.io. You will see realistic patterns: from “I only have cPanel” to “I’m ready to run reverse proxies, queues and Redis on a VPS.” By the end, you should have a basis for choosing a platform, planning capacity tests, and identifying the deployment requirements for your real‑time stack.

The examples use Laravel 13 / PHP 8.3+, Reverb 1.12 and Echo 2.6, followed by a separate Node.js 22 / Socket.IO 4 application. The Laravel example assumes an existing application with working session login, a database, Composer and a Vite build environment. Run application commands in that application’s directory and review dependencies and pending migrations in staging before deployment.

To choose the right hosting, it helps to understand what makes WebSockets different from normal HTTP.

HTTP vs WebSocket in simple terms

Traditional HTTP is request/response based. The browser requests a page or API response and the server replies; the underlying connection can remain open for further requests. For live features such as chat, notifications and counters, HTTP-based options include:

  • Short polling: The browser hits an endpoint every few seconds.
  • Long polling: The server keeps the request open until there is new data or a configured timeout.
  • Server‑Sent Events (SSE): A one‑way stream from server to browser.

WebSocket is different. In the usual HTTP/1.1 setup, an initial HTTP handshake upgrades the connection to a persistent, full‑duplex WebSocket channel over TCP. Browser and server can push data to each other at any time without reopening connections.

For real‑time apps, that means:

  • Less per-message HTTP request overhead than polling.
  • No polling-interval delay for server-pushed events once the socket is open.
  • Ability to push events to many online users, subject to server and network capacity.

Where Node.js, Laravel Echo and Socket.io fit

The supported stacks in this guide have distinct roles:

  • Laravel: application logic, HTTP endpoints, authentication, channel authorization and queued broadcast jobs.
  • Reverb: a PHP WebSocket server using the Pusher protocol; it receives Laravel broadcasts through a supported publishing API.
  • Laravel Echo with pusher-js: the browser subscriber for the Reverb example.
  • Socket.IO with socket.io-client: a separate Node.js realtime stack with its own event and room protocol. It is not a raw WebSocket client and does not connect to Reverb by swapping package names.

You can also build everything purely in Node.js (Express + Socket.io or NestJS + WebSockets), rather than combining Laravel and Node.js.

Shared Hosting vs VPS for WebSocket and Real‑Time Workloads

If your application already runs on shared hosting, check what the plan actually permits: persistent processes, WebSocket proxying, queue workers, supported runtimes and resource limits. The panel name alone does not establish those capabilities.

Constraints of typical shared hosting

On classic shared hosting with cPanel or DirectAdmin, the provider usually gives you:

  • Supported PHP versions and a managed web/database stack.
  • Defined CPU, memory, I/O and process limits, sometimes enforced through CloudLinux or similar tooling.
  • Provider-controlled ports, firewall and reverse-proxy configuration.
  • Potential Node.js or persistent-process support that must be checked against the specific WebSocket requirements.

WebSocket connections are served by a long-running process. A plan may support that process through its application platform, restrict it, or terminate it under limits. Verify:

  • Whether the process remains running and restarts after failure or host maintenance.
  • How its local endpoint is exposed through the provider’s proxy.
  • Whether upgrade requests, streaming, heartbeat timing and concurrent connections fit the plan.

There are some shared hosting platforms with integrated Node.js support, but even then you often have strict limits on memory, CPU and concurrent connections. Whether those limits become a bottleneck depends on concurrent connections, message frequency, payload size and application work; Node.js support alone does not establish suitability.

What shared hosting is still good for

Shared hosting can still be suitable for these uses, within the plan’s runtime and resource limits:

  • Your main PHP/Laravel site and classic HTTP APIs.
  • Simple real‑time‑like features implemented via AJAX polling where a few seconds of delay is acceptable. SSE is another option if the host permits long-lived streaming responses without buffering or restrictive timeouts; concurrent streams must fit its resource limits.
  • Using an external real‑time platform (a hosted WebSocket/broadcasting SaaS) via HTTP APIs from your PHP app.

If you are interested in a deeper PHP framework comparison on shared environments, we wrote a detailed guide on choosing between shared hosting and VPS for Laravel and other PHP frameworks. Many of the same trade‑offs apply directly to real‑time workloads.

What changes when you move to a VPS

For a self-managed VPS deployment, look for the following capabilities in the chosen plan:

  • Appropriate administrative access to install supported runtimes, Reverb, optional Redis and queue workers.
  • Control of guest firewall rules within provider policy; public HTTPS at 443, with application ports such as 8080 and 3000 kept private.
  • A process manager such as systemd or Supervisor for PHP processes, or systemd/PM2 for Node.js.
  • A configurable reverse proxy and TLS certificates. Allocated vCPU counts do not imply dedicated physical cores; resource guarantees remain plan-specific.

At that point, you are building a small “real” backend architecture, not just uploading PHP files. If you want a step‑by‑step look at what that feels like in production, we documented it in our guide on hosting Node.js in production with PM2, Nginx, SSL and zero‑downtime deploys.

For an initial capacity test of a small chat or notification system, you might start with a VPS offering 1–2 vCPU and 2–4 GB RAM. Treat this as a trial configuration, not a capacity guarantee: concurrent connections, event fan-out and any colocated database or workers determine the resources needed. In our article on choosing VPS specs for WooCommerce, Laravel and Node.js, we go deeper into sizing CPU, RAM and storage for application servers; the same logic applies to WebSocket workloads.

Architecture Patterns for Node.js, Laravel Echo and Socket.io

The following architecture options range from a single-server deployment to a split stack.

Pattern 1: Everything on one VPS

A single-VPS layout for a small to medium project could use:

  • Nginx: public TLS entry point.
  • PHP-FPM and Laravel: application routes, session login and private-channel authorization.
  • Database queue and worker: asynchronous broadcasts in the example below.
  • Reverb: loopback WebSocket/publishing service on port 8080.
  • Optional Redis: explicitly configured queue/cache or Reverb scaling, not a universal broadcast bridge.

The old laravel-echo-server repository is archived and its 1.6.3 package depends on Socket.IO 2.x. BeyondCode Laravel WebSockets also states that it is no longer maintained and recommends Reverb. These are not the installation path for the Laravel 13 example; an existing legacy deployment needs its own compatibility and migration assessment.

Nginx routes the Laravel site separately from the Reverb browser connection:

  • https://app.example.com → Laravel / PHP-FPM, including /broadcasting/auth.
  • wss://ws.example.com/app/... → Reverb on 127.0.0.1:8080.
  • Laravel queue worker → Reverb publishing API at http://127.0.0.1:8080/apps/....

A single VPS reduces the number of hosts to manage, but the services still compete for resources and share a failure domain. Decide whether it is suitable through capacity and recovery tests.

Pattern 2: Shared hosting for Laravel, VPS for real‑time

If you already have a stable Laravel application on shared hosting and want to add WebSockets without migrating everything, a practical compromise is:

  • Keep Laravel and its database on the existing plan if their requirements remain supported.
  • Place Reverb, or a separately designed Node.js realtime application, on an appropriately sized VPS.
  • Provide the queue processing and a compatible publishing integration. A generic WebSocket URL or shared Redis address is not sufficient.
  • Point ws.example.com to the VPS and provide TLS. Laravel must reach Reverb’s publishing API over HTTPS or a protected private network; localhost addresses do not cross machines.

The long-lived connection process can move to the VPS while Laravel continues serving HTTP. Channel authorization still needs the application’s session or token system. Configure cookie, origin and network access deliberately; CORS does not supply identity or channel permissions.

Pattern 3: Full Node.js stack with Laravel only as API

A project can instead use Node.js for its realtime features while keeping Laravel as a separate API or administration application:

  • Serve the frontend at app.example.com and a compatible Socket.IO service at ws.example.com.
  • Use Laravel as a separate API if needed.
  • Define a supported identity/token contract and server-side room permissions. Merely selecting JWT or OAuth does not implement cross-application authentication or an event bridge.

From the hosting side, this looks more like a classic Node.js SaaS architecture and benefits strongly from the practices we detail in our comparison of where to host Node.js apps: cPanel, shared hosting or VPS.

Running WebSockets on Shared Hosting: What Is Actually Possible?

On a shared plan, compare three practical choices against the features your application actually needs.

Option 1: Simulate “real‑time” without WebSockets

If your use case is not ultra‑latency‑sensitive, you can often get away with:

  • Short polling: choose the polling interval from acceptable delay and request volume.
  • Long polling: requests remain open until data or an application timeout; this still occupies server resources.
  • SSE: one-way streaming from server to browser, requiring a host/proxy that permits streaming without disruptive buffering or timeouts.

These options may avoid a separate WebSocket daemon, but long polling and SSE still maintain requests. Check worker/concurrency limits and query volume rather than assuming that ordinary PHP support makes them suitable.

Option 2: Use an external real‑time service

Another pattern is to use a third‑party real‑time platform (a hosted WebSocket/broadcasting SaaS) that provides:

  • A WebSocket endpoint your frontend can connect to.
  • HTTP APIs your PHP/Laravel app can call to publish events.
  • SDKs for presence channels, private channels, etc.

The service handles persistent client connections while your application retains business logic, permissions and publishing. Queued Laravel broadcasts still require a worker. Compare event/connection quotas, region latency, outage behavior and total operating cost; a managed service may remain appropriate long term.

Option 3: Move only the WebSocket piece to a VPS

You can keep Laravel on the existing host and move Reverb to a VPS, provided the publishing API and private-channel authorization paths are accessible and protected. The one-host example below uses loopback and must be adapted when services run on different machines.

Setting Up WebSockets on a VPS: A Practical Blueprint

The following blueprint is a single-VPS Laravel/Reverb example, plus an independent Socket.IO demo. It assumes:

  • A supported Linux environment, PHP 8.3+ and an existing Laravel 13 application with working session login.
  • Nginx, valid TLS certificates and app.example.com for the Laravel site.
  • Database queue migrations, a queue worker and a Composer/Vite build environment.
  • Reverb 1.12.0, Echo 2.6.3 and Pusher JS 8.6.0 as the example version set; supported Node.js 22 for build tooling and the later Socket.IO demo.

Step 1: Basic server hardening and web stack

Before touching WebSockets, prepare the VPS properly:

  1. Prepare supported packages and a deliberate security-update process.
  2. Allow required web traffic and preserve necessary management access. Keep the application’s 8080/3000 ports on loopback in this one-host layout.
  3. Configure the Laravel web root, PHP-FPM, database, application user and TLS before testing broadcasts.

If you want a structured checklist for initial VPS hardening, our guide on VPS security hardening (sshd_config, Fail2ban, disabling direct root) is a good reference. Investing in this early saves a lot of trouble later.

Step 2: Install Reverb and Configure Its Addresses

Run the installer inside the existing Laravel project. Retain its generated REVERB_APP_ID, REVERB_APP_KEY and REVERB_APP_SECRET; the secret stays server-side. Review existing migration changes before running migrate and confirm the database queue’s jobs table exists.

php artisan install:broadcasting --reverb
composer require laravel/reverb:1.12.0
npm install --save-dev laravel-echo@2.6.3 pusher-js@8.6.0
php artisan migrate
php artisan channel:list

Use the following values in the application environment. The installer supplies the application credentials; these additional values separate listener, publisher and browser addresses:

BROADCAST_CONNECTION=reverb
QUEUE_CONNECTION=database
DB_QUEUE_RETRY_AFTER=90

REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=8080
REVERB_HOST=127.0.0.1
REVERB_PORT=8080
REVERB_SCHEME=http

VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST=ws.example.com
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https

REVERB_SERVER_HOST/PORT controls the listener. REVERB_HOST/PORT/SCHEME is Laravel’s publishing destination. Both use loopback here. The VITE_REVERB_* values are the public browser TLS endpoint and take effect after a new frontend build.

In config/reverb.php, restrict the application’s allowed_origins to ['app.example.com']. Confirm that routes/channels.php and /broadcasting/auth were registered by installation. If needed, add channels: __DIR__.'/../routes/channels.php' to withRouting in bootstrap/app.php. Origin checking and channel authorization solve different problems.

Use separate managed processes for Reverb and the queue worker; a running HTTP site does not process queued broadcasts by itself. Process lifecycle details appear after the frontend example.

Step 3: Create a Queued Event and Authorized Publisher

The event below targets the signed-in user’s private channel. broadcastWith sends only the intended message field rather than every public property.

<?php
// app/Events/NoticeCreated.php
namespace App\Events;

use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class NoticeCreated implements ShouldBroadcast
{
    public function __construct(
        public string $userId,
        public string $message,
    ) {}

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

    public function broadcastAs(): string
    {
        return 'notice.created';
    }

    public function broadcastWith(): array
    {
        return ['message' => $this->message];
    }
}

Register the per-user authorization callback and retain any other application channels:

<?php
// routes/channels.php: retain any other application channels.
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('users.{userId}', function (User $user, string $userId) {
    return (string) $user->getAuthIdentifier() === $userId;
});

Add the following to routes/web.php without duplicating existing imports. The recipient comes from the authenticated session, not a client-supplied user ID. The web middleware provides CSRF handling; auth and throttle apply to the publisher.

// Add to routes/web.php, below its existing PHP opening tag.
use App\Events\NoticeCreated;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::view('/realtime-demo', 'realtime-demo')->middleware('auth');

Route::post('/realtime-demo/notice', function (Request $request) {
    $data = $request->validate(['message' => ['required', 'string', 'max:200']]);
    event(new NoticeCreated(
        (string) $request->user()->getAuthIdentifier(),
        $data['message'],
    ));
    return response()->json(['queued' => true], 202);
})->middleware(['auth', 'throttle:20,1']);

HTTP 202 means queued, not delivered. The database worker must run before the event reaches Reverb. This is an ephemeral notification example, not durable message history.

Step 4: Nginx reverse proxy for WebSockets

For an HTTPS application, expose the browser WebSocket endpoint over wss:// with this routing pattern:

  • Terminate browser TLS at Nginx on port 443.
  • Proxy Reverb browser subscriptions under /app/ to loopback port 8080.
  • Proxy the optional, separate Socket.IO application under /socket.io/ to loopback port 3000.

With a valid certificate already installed for ws.example.com, use this configuration. The map belongs in the HTTP context; enable only the location blocks your deployment uses.

# map belongs in the http context, outside server blocks.
map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}

server {
    listen 443 ssl;
    server_name ws.example.com;
    ssl_certificate     /etc/letsencrypt/live/ws.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ws.example.com/privkey.pem;

    # Reverb browser subscriptions; Laravel publishes over loopback :8080.
    location /app/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 120s;
        proxy_send_timeout 120s;
    }

    # Separate, optional Socket.IO example.
    location /socket.io/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 120s;
        proxy_send_timeout 120s;
    }

    location / { return 404; }
}

No URI suffix on proxy_pass means the request path is preserved. proxy_read_timeout measures the gap between upstream reads, not total connection lifetime. The 120-second value exceeds Socket.IO’s default 25-second ping interval plus 20-second ping timeout; recheck it when heartbeat settings change. Validate the actual configuration with nginx -t. Reverb’s /apps publishing API is reached over loopback here and is not publicly proxied; a split-host layout needs an accessible HTTPS publishing path or protected private-network connection.

Cloudflare supports WebSockets, but its WAF checks the opening HTTP handshake rather than subsequent WebSocket messages. Keep message authorization and rate limits in the application, protect direct origin access, and expect reconnections after network or edge changes. The Cloudflare, WebSocket and gRPC guide covers the connection layer.

Step 5: Subscribe with Echo and Run the Workers

Create the following Blade view. Add resources/js/realtime-demo.js to the Laravel plugin’s input list in vite.config.js, keeping existing entries. This separate page starts only the Echo instance shown here.

<!-- resources/views/realtime-demo.blade.php -->
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <meta name="user-id" content="{{ auth()->id() }}">
    <title>Realtime demo</title>
    @vite('resources/js/realtime-demo.js')
</head>
<body>
    <form id="notice-form">
        <label>Message <input name="message" maxlength="200" required></label>
        <button id="send" disabled>Send</button>
    </form>
    <p id="status" role="status">Connecting</p>
    <p id="notice"></p>
</body>
</html>

The JavaScript uses pusher-js with the Reverb broadcaster. The Send button becomes available after private-channel subscription. The custom broadcast name requires the leading dot in .notice.created.

Create resources/js/realtime-demo.js:

// resources/js/realtime-demo.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;
const csrf = document.querySelector('meta[name="csrf-token"]').content;
const userId = document.querySelector('meta[name="user-id"]').content;
const status = document.querySelector('#status');
const send = document.querySelector('#send');
let deliverySeen = false;
const echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: Number(import.meta.env.VITE_REVERB_PORT),
    wssPort: Number(import.meta.env.VITE_REVERB_PORT),
    forceTLS: import.meta.env.VITE_REVERB_SCHEME === 'https',
    enabledTransports: ['ws', 'wss'],
    authEndpoint: '/broadcasting/auth',
    auth: { headers: { 'X-CSRF-TOKEN': csrf } },
});

echo.private(`users.${userId}`)
    .subscribed(() => { send.disabled = false; status.textContent = 'Subscribed'; })
    .error(() => { send.disabled = true; status.textContent = 'Subscription failed'; })
    .listen('.notice.created', event => {
        deliverySeen = true;
        document.querySelector('#notice').textContent = event.message;
        status.textContent = 'Event received';
    });

echo.connector.pusher.connection.bind('state_change', ({ current }) => {
    if (current !== 'connected') send.disabled = true;
});

document.querySelector('#notice-form').addEventListener('submit', async event => {
    event.preventDefault();
    deliverySeen = false;
    status.textContent = 'Submitting';
    try {
        const response = await fetch('/realtime-demo/notice', {
            method: 'POST',
            credentials: 'same-origin',
            headers: {
                'Content-Type': 'application/json',
                'Accept': 'application/json',
                'X-CSRF-TOKEN': csrf,
            },
            body: JSON.stringify({ message: event.target.elements.message.value }),
        });
        if (!response.ok) throw new Error('Request failed');
        if (!deliverySeen) status.textContent = 'Queued; waiting for delivery';
    } catch {
        status.textContent = 'Request failed; check session or validation';
    }
});

Build the frontend and initially run Reverb and the database worker in separate terminals:

php artisan config:cache
npm run build
php artisan reverb:start
# In a separate process:
php artisan queue:work database --queue=default --sleep=1 --tries=3 --timeout=60

The 60-second worker timeout is below the 90-second database reservation interval; CLI PHP needs PCNTL for timeout handling. Give external I/O a shorter budget, and recalculate both limits for longer jobs. Three tries includes the first attempt. In production, configure separate Supervisor or systemd processes under the application user, with correct PHP paths, startup, restart and log retention. A 75-second manager stop allowance is an example for this 60-second worker budget. Rebuild cached configuration and restart long-lived processes after relevant configuration changes; Vite endpoint changes also need a new build.

Separate Example: Authenticated Socket.IO Rooms

This demo uses Node.js 22.23.4, Socket.IO 4.8.4 and jose 6.2.15. It does not integrate with Laravel/Reverb. In a new test directory, install dependencies, create the three files below, then run the final three commands in the same trusted terminal. Alice may access only room:red; Bob only room:blue. The generated key stays server-side and tokens expire after five minutes.

npm init -y
npm install --save-exact socket.io@4.8.4 socket.io-client@4.8.4 jose@6.2.15
export DEMO_JWT_KEY_HEX="$(openssl rand -hex 32)"
node token.mjs alice
node server.mjs
// key.mjs: server-side only.
const hex = process.env.DEMO_JWT_KEY_HEX ?? '';
if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error('Set a 32-byte demo key');
export const key = Buffer.from(hex, 'hex');
// token.mjs: run only in the trusted demo server terminal.
import { SignJWT } from 'jose';
import { key } from './key.mjs';
const user = process.argv[2];
if (!['alice', 'bob'].includes(user)) throw new Error('Use alice or bob');
console.log(await new SignJWT({})
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuer('realtime-demo').setAudience('realtime-demo')
    .setSubject(user).setIssuedAt().setExpirationTime('5m')
    .sign(key));

The server verifies signature, issuer, audience, algorithm and token age; it checks room permission again for publishing. The origin filter limits browser origins but does not replace identity or authorization. CORS alone does not protect WebSocket access.

// server.mjs
import { createServer } from 'node:http';
import { Server } from 'socket.io';
import { jwtVerify } from 'jose';
import { key } from './key.mjs';

const origin = 'https://app.example.com';
const acl = new Map([
    ['alice', new Set(['room:red'])],
    ['bob', new Set(['room:blue'])],
]);
const http = createServer((request, response) => {
    response.writeHead(request.url === '/health' ? 200 : 404);
    response.end(request.url === '/health' ? 'ok' : 'not found');
});
const io = new Server(http, {
    cors: { origin, methods: ['GET', 'POST'] },
    allowRequest: (request, done) => done(null, request.headers.origin === origin),
    maxHttpBufferSize: 16_384,
});

io.use(async (socket, next) => {
    try {
        const { payload } = await jwtVerify(socket.handshake.auth.token, key, {
            algorithms: ['HS256'], issuer: 'realtime-demo', audience: 'realtime-demo',
            requiredClaims: ['sub', 'iat', 'exp'], maxTokenAge: '5m',
        });
        if (!acl.has(payload.sub) || payload.exp - payload.iat > 300) {
            throw new Error('Invalid identity or lifetime');
        }
        socket.data.user = payload.sub;
        socket.data.expires = payload.exp * 1000;
        next();
    } catch {
        next(new Error('Unauthorized'));
    }
});

io.on('connection', socket => {
    const timer = setTimeout(() => socket.disconnect(true),
        Math.max(0, socket.data.expires - Date.now()));
    socket.on('disconnect', () => clearTimeout(timer));
    let windowStart = Date.now(), count = 0;
    socket.use((packet, next) => {
        if (Date.now() >= socket.data.expires) return socket.disconnect(true);
        if (Date.now() - windowStart >= 10_000) { windowStart = Date.now(); count = 0; }
        if (++count > 20) return socket.disconnect(true);
        next();
    });
    socket.on('join', async (room, callback) => {
        const reply = typeof callback === 'function' ? callback : () => {};
        if (!acl.get(socket.data.user)?.has(room)) return reply({ ok: false });
        await socket.join(room);
        reply({ ok: true });
    });
    socket.on('publish', (data, callback) => {
        const reply = typeof callback === 'function' ? callback : () => {};
        if (!data || !acl.get(socket.data.user)?.has(data.room)
            || !socket.rooms.has(data.room) || typeof data.text !== 'string'
            || !data.text.trim() || data.text.length > 200) return reply({ ok: false });
        io.to(data.room).emit('notice', { text: data.text, sender: socket.data.user });
        reply({ ok: true });
    });
});

http.listen(3000, '127.0.0.1');
for (const signal of ['SIGINT', 'SIGTERM']) {
    process.once(signal, () => io.close(() => process.exit(0)));
}

Bundle the client below into an HTTPS page at app.example.com. Paste the short-lived Alice token from the trusted terminal into the demo prompt; do not put it in a URL. It rejoins and sends Hello after each connection, so this sample is not suitable for non-idempotent business actions.

// Bundle into an HTTPS page at https://app.example.com.
import { io } from 'socket.io-client';

const token = window.prompt('Paste the short-lived alice demo token');
const socket = io('https://ws.example.com', { auth: { token }, autoConnect: false });
socket.on('notice', event => { console.log('Notice:', event.text); });
socket.on('connect_error', () => { console.log('Connection or authorization failed'); });
socket.on('disconnect', reason => {
    if (reason === 'io server disconnect') console.log('Obtain a fresh token before reconnecting');
});
socket.on('connect', () => {
    socket.timeout(5000).emit('join', 'room:red', (error, joined) => {
        if (error || !joined?.ok) return console.log('Join failed');
        socket.timeout(5000).emit('publish', { room: 'room:red', text: 'Hello' },
            (error, result) => console.log(error || !result?.ok ? 'Publish failed' : 'Accepted'));
    });
});
socket.connect();

The 20-event / 10-second limit is per connection and resets on reconnect. Production needs shared user/IP limits, current permissions and a revocation/disconnection path. Replace the static demo ACL and token command with your real identity provider. On token expiry the server disconnects the client; acquire a fresh token before reconnecting. An acknowledgement means accepted by this application, not persisted or delivered to every recipient.

For a systemd 252 host, a Node service declaration can be:

[Unit]
Description=Socket.IO demo
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=simple
User=realtime
WorkingDirectory=/srv/realtime
EnvironmentFile=/etc/realtime-demo.env
ExecStart=/opt/node22/bin/node /srv/realtime/server.mjs
Restart=always
RestartSec=5
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

First prepare the realtime user, /srv/realtime files and actual Node 22 binary path. The root-readable /etc/realtime-demo.env file contains DEMO_JWT_KEY_HEX=<64-hex-character-key>, matching the token issuer’s key; it is not a shell script. Validate the unit with systemd-analyze verify, then apply daemon-reload and enable –now through an authorized administrator account. Configure journal retention and alert if restart limits stop the service. Use one manager for a given process.

Performance, Scaling and Resource Planning for Real‑Time Apps

WebSockets change the resource profile of your server. Instead of lots of short‑lived HTTP requests, you now have many concurrent open connections. That affects how you size and tune your VPS.

Key resource dimensions

  • CPU: parsing, encoding, application work and message fan-out. A core count alone does not establish throughput.
  • Memory: application/connection state and kernel buffers, plus PHP-FPM, database, worker and optional Redis memory.
  • Network: message size, delivery fan-out, latency and jitter. Storage performance can matter for database and persistent queue work; it does not directly remove network delay. The NVMe VPS guide explains storage trade-offs.

Connection counts and limits

Do not infer a connection limit from RAM alone. For a hypothetical calculation, reserving 1.2 GB for connections and assuming 30 KB per connection gives 40,000 using decimal units. Those invented inputs illustrate division, not a benchmark; they omit kernel overhead, CPU, traffic bursts and other services.

  • Per-process file descriptors, reverse-proxy connection limits and kernel memory.
  • TCP accept queues and connection churn; backlog settings are not established-connection capacity figures.
  • Event-loop delay, queue age and fan-out storms, such as emitting a room-wide update on every keystroke.

As your app grows, you can:

  • Use Reverb’s supported Redis scaling or the appropriate Socket.IO adapter for that stack. Sharing Redis does not bridge the two protocols.
  • With Socket.IO HTTP long-polling enabled, multiple nodes need session affinity as well as an adapter. The Redis adapter does not remove affinity requirements or provide durable replay.
  • Separate database and connection services when measurements justify it; secure Redis with network restrictions, credentials/ACLs and TLS where needed.
  • Apply event debouncing, payload limits and shared rate limits. A Redis prefix or logical database does not isolate memory, failure or eviction domains.

Measure before guessing

Use a compatible client for the actual protocol: Echo/Pusher for Reverb, and socket.io-client or a verified Socket.IO-capable test engine for Socket.IO. A generic k6/Locust raw WebSocket handshake does not test Engine.IO negotiation, room authorization or event handling. Exercise allowed and denied subscriptions, invalid/expired credentials, message delivery, reconnect waves and worker delay, then apply realistic payload sizes and fan-out. Small functional tests do not establish capacity. The hosting load-testing guide provides the measurement approach.

Security and Reliability Checklist for WebSocket Hosting

Protect the handshake, channel permissions, messages and operational lifecycle separately:

1. Protect Browser Traffic with TLS

Serve browser WebSockets over wss:// for HTTPS applications. This protects traffic in transit. In the examples, Nginx terminates TLS and the backend hop stays on the same host’s loopback interface; cross-host hops need their own protected transport and access policy.

2. Strict authentication and authorization

  • Authorize Laravel private/presence channels against current user/room access; an origin allowlist is not authorization.
  • Validate Socket.IO credentials at connection and permissions for each protected event. Connection middleware runs once per connection, not before every message.
  • Handle expiry and revocation for active connections. The Socket.IO demo disconnects at expiry; immediate account/session revocation still needs an application-level mechanism. Laravel authorization at subscription likewise does not continually re-evaluate existing subscriptions.

3. Rate limiting and abuse protection

WebSockets can be abused by:

  • Opening many concurrent connections from the same IP.
  • Flooding the server with messages.
  • Attempting to brute‑force channel names or auth tokens.

Defences include:

  • Apply connection limits with a deliberate trusted-proxy/client-IP configuration.
  • Use application-level per-user event limits and payload bounds; a per-socket demo limit is not a distributed abuse control.
  • Use WAF rules for the HTTP handshake while retaining message validation and authorization in the application.

For broader WAF and bot‑blocking strategies around your real‑time app, our article on layered WAF and bot protection with Cloudflare, ModSecurity and Fail2ban gives a practical, hosting‑side view.

4. Robust process supervision and logging

  • Manage Reverb, queue workers and Node.js with appropriate startup/restart policies and one manager per process.
  • Centralize errors, reconnect counts and correlation IDs without recording tokens or private message bodies.
  • Monitor HTTP liveness plus an authorized subscription-and-delivery check; a successful /health response alone does not prove realtime delivery.

We have written several guides on VPS resource monitoring and setting up Prometheus + Grafana + exporters for alerting; these same tools work well for tracking connection counts, CPU spikes and sustained memory growth in real‑time stacks.

5. Plan for graceful deploys

Restarting a connection process disconnects its active clients. Plan for:

  • Drain or deliberately close old connections, then let clients reconnect with fresh authorization. Reverb restart does not preserve existing connections.
  • Use bounded retry/backoff and resubscription; a rolling release or systemd reload alone cannot guarantee connection continuity.
  • Keep message versions compatible during overlap. Socket.IO defaults to at-most-once delivery; missed server events are not automatically replayed. Durable history, offsets and idempotency are needed when loss or duplicates matter.
  • Before DNS migration, lower TTL and wait out the old TTL. TTL neither allocates a traffic percentage nor moves open connections; weighted DNS or a load balancer is a separate mechanism, and DNS caching prevents exact user-percentage control.

The VPS deployment guide covers release mechanics. Realtime applications also need recovery tests for live connection state and durable data: protect code, lock files, configuration and databases; define Redis queue/session persistence and eviction policies instead of treating all Redis data as disposable cache.

Choose Hosting Through Protocol Checks and Measurement

Laravel Reverb/Echo and Socket.IO offer different supported realtime paths. Choose the protocol first, then verify the complete publisher, queue, authorization and subscriber flow. Hosting must support all the required processes and network paths.

A shared plan may work when its runtime, proxy and resource limits fit; a managed realtime service can also remain a long-term choice. A VPS gives more process and network control with added operating responsibility. Multiple VPS instances or a dedicated server may suit measured resource or availability needs, but one larger server does not create redundancy.

Compare plans using concurrent connections, message frequency and size, fan-out, latency targets and colocated services. Include update ownership, log retention, backup recovery and reconnect behavior in the comparison. A tested application flow and measured operating margin are more useful than an unsupported connection-count promise.

Primary Technical References

↑