PHP WebSocket server: three approaches, measured and compared

PHP WebSocket server: three approaches, measured and compared
Quick answer

Three routes: Ratchet in pure Composer, native sockets with no dependency, or OpenSwoole as a PECL extension. All three need a permanent process, so a VPS or a container, never shared hosting. The measured deciding factor is memory per connection: 22.4 KB for Ratchet, 5.3 KB for native sockets, close to zero with OpenSwoole.

PHP can hold WebSocket connections open, provided you accept an execution model it does not have by default: a process that runs continuously instead of a script that is born and dies with every request. This article builds an echo server with the three approaches available in 2026, Ratchet, native sockets, OpenSwoole, and compares them on measurements taken on the same machine.

What you need to know before you start

A WebSocket server is not deployed like a PHP website. It needs a permanent process, a supervisor that restarts it when it falls over, and a front-end server that terminates TLS and relays the connection. On shared hosting this is almost always impossible: long-running processes are forbidden there, and so is installing PECL extensions. You need a VPS or a container.

Second point: a PHP process that runs for days no longer has the safety net of a restart. A memory leak will eventually kill the service. Static variables, logs piling up in memory and database connections left open all become real problems.

Ratchet, the library approach

Ratchet is the long-standing WebSocket library of the PHP world, built on the ReactPHP event loop. It installs without compiling anything.

bash
composer require cboden/ratchet

Watch the namespaces: the separators are backslashes, and the examples floating around often lose them on the way from one editor to another. Without them, the code throws a fatal error.

src/Chat.php
<?php

declare(strict_types=1);

namespace MyApp;

use Ratchet\ConnectionInterface;
use Ratchet\MessageComponentInterface;
use SplObjectStorage;

final class Chat implements MessageComponentInterface
{
    private SplObjectStorage $clients;

    public function __construct()
    {
        $this->clients = new SplObjectStorage();
    }

    public function onOpen(ConnectionInterface $conn): void
    {
        $this->clients->attach($conn);
        echo "Connexion {$conn->resourceId}\n";
    }

    public function onMessage(ConnectionInterface $from, $msg): void
    {
        foreach ($this->clients as $client) {
            if ($from !== $client) {
                $client->send($msg);
            }
        }
    }

    public function onClose(ConnectionInterface $conn): void
    {
        $this->clients->detach($conn);
    }

    public function onError(ConnectionInterface $conn, \Throwable $e): void
    {
        error_log('WebSocket : ' . $e->getMessage());
        $conn->close();
    }
}

The class lives in src/ under the MyApp namespace: declare it to Composer, otherwise server.php stops with Class "MyApp\Chat" not found.

composer.json
{
    "autoload": {
        "psr-4": {
            "MyApp\\": "src/"
        }
    }
}
bash
composer dump-autoload

The type of $e deserves a note. In a file with namespace MyApp;, writing Exception $e refers to \MyApp\Exception, a class that does not exist, and the signature no longer matches the interface. You need the leading backslash, \Throwable or \Exception.

server.php
<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use MyApp\Chat;

$server = IoServer::factory(
    new HttpServer(new WsServer(new Chat())),
    8080,
    '0.0.0.0',
);

$server->run();
bash
php server.php

One clarification about broadcasting to every client: IoServer has no broadcast() method. Introspection confirms it.

php
$r = new ReflectionClass(Ratchet\Server\IoServer::class);
echo implode(', ', array_map(fn($m) => $m->getName(), $r->getMethods(ReflectionMethod::IS_PUBLIC)));
// __construct, factory, run, handleConnect, handleData, handleEnd, handleError

Broadcasting means walking the collection of connections yourself, as in onMessage() above.

The state of the project

This is the point that should weigh on the decision. cboden/ratchet is at version 0.4.4, released on 14 December 2021. ratchet/rfc6455, its protocol core, dates from 9 December 2021. Almost five years without a release. The ReactPHP dependencies, on the other hand, are current (react/socket 1.17.0, November 2025).

The concrete consequence on PHP 8.5: merely loading the classes emits three deprecation notices.

code
Deprecated: Ratchet\Server\IoServer::__construct(): Implicitly marking parameter
$loop as nullable is deprecated, the explicit nullable type must be used instead
Deprecated: Ratchet\Http\HttpServerInterface::onOpen(): Implicitly marking parameter
$request as nullable is deprecated, the explicit nullable type must be used instead
Deprecated: Ratchet\WebSocket\WsServer::onOpen(): Implicitly marking parameter
$request as nullable is deprecated, the explicit nullable type must be used instead

The server works, but it pollutes the logs and nothing guarantees it will survive PHP 9. If you are starting a project today, look at ReactPHP directly as well, or at AmPHP, both are actively maintained.

With no dependency at all

The WebSocket protocol fits into very little code: an HTTP handshake, then a binary frame format. For a simple need, writing it yourself saves adding thirteen packages to the project.

serveur-natif.php
<?php

declare(strict_types=1);

$serveur = stream_socket_server('tcp://0.0.0.0:8081', $errno, $errstr);
if (!$serveur) {
    fwrite(STDERR, "Écoute impossible : {$errstr}\n");
    exit(1);
}
stream_set_blocking($serveur, false);

$clients = [];
$prets   = [];

function poigneeDeMain($socket): bool
{
    $requete = fread($socket, 8192);
    if (!$requete || !preg_match('#Sec-WebSocket-Key:\s*(\S+)#i', $requete, $m)) {
        return false;
    }
    // The constant below is mandated by RFC 6455
    $accept = base64_encode(sha1($m[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
    fwrite($socket, "HTTP/1.1 101 Switching Protocols\r\n"
        . "Upgrade: websocket\r\nConnection: Upgrade\r\n"
        . "Sec-WebSocket-Accept: {$accept}\r\n\r\n");
    return true;
}

function lireTrame($socket): ?string
{
    $entete = fread($socket, 2);
    if ($entete === false || strlen($entete) < 2) {
        return null;
    }
    $second  = ord($entete[1]);
    $masquee = (bool) ($second & 0x80);
    $taille  = $second & 0x7F;

    if ($taille === 126) {
        $taille = unpack('n', fread($socket, 2))[1];
    } elseif ($taille === 127) {
        $taille = unpack('J', fread($socket, 8))[1];
    }

    $masque  = $masquee ? fread($socket, 4) : '';
    $donnees = '';
    while (strlen($donnees) < $taille) {
        $bloc = fread($socket, $taille - strlen($donnees));
        if ($bloc === '' || $bloc === false) {
            break;
        }
        $donnees .= $bloc;
    }

    if ($masquee) {
        for ($i = 0; $i < strlen($donnees); $i++) {
            $donnees[$i] = $donnees[$i] ^ $masque[$i % 4];
        }
    }
    return $donnees;
}

function ecrireTrame($socket, string $charge): void
{
    $taille = strlen($charge);
    $trame  = chr(0x81); // FIN + text opcode

    if ($taille < 126) {
        $trame .= chr($taille);
    } elseif ($taille < 65536) {
        $trame .= chr(126) . pack('n', $taille);
    } else {
        $trame .= chr(127) . pack('J', $taille);
    }
    fwrite($socket, $trame . $charge);
}

while (true) {
    $lecture = array_merge([$serveur], $clients);
    $ecriture = $exception = null;

    if (@stream_select($lecture, $ecriture, $exception, 0, 200000) === false) {
        continue;
    }

    foreach ($lecture as $socket) {
        if ($socket === $serveur) {
            $nouveau = @stream_socket_accept($serveur, 0);
            if ($nouveau) {
                stream_set_blocking($nouveau, false);
                $clients[(int) $nouveau] = $nouveau;
                $prets[(int) $nouveau]   = false;
            }
            continue;
        }

        $id = (int) $socket;

        if (!($prets[$id] ?? false)) {
            if (poigneeDeMain($socket)) {
                $prets[$id] = true;
            } else {
                fclose($socket);
                unset($clients[$id], $prets[$id]);
            }
            continue;
        }

        // Flush every pending frame, not one per loop iteration
        stream_set_blocking($socket, true);
        $ferme = false;
        do {
            $message = lireTrame($socket);
            if ($message === null || $message === '') {
                $ferme = true;
                break;
            }
            ecrireTrame($socket, $message); // echo
            $meta = stream_get_meta_data($socket);
        } while (($meta['unread_bytes'] ?? 0) > 0);
        stream_set_blocking($socket, false);

        if ($ferme) {
            fclose($socket);
            unset($clients[$id], $prets[$id]);
        }
    }
}

The comment about flushing pending frames is not a detail. Our first version handled one message per client per loop iteration: the measured throughput collapsed, and the cause was our loop, not the PHP sockets. That is the kind of mistake a benchmark exposes and a code reading lets through.

This server stays deliberately incomplete. It handles neither control frames (ping, pong, close), nor fragmentation, nor binary payloads, nor TLS. For real use you have to add them or move to a library.

OpenSwoole

OpenSwoole is a C extension that gives PHP a genuine asynchronous, multi-process server. It installs through PECL and therefore needs administrator access to the machine.

bash
pecl install openswoole
docker-php-ext-enable openswoole   # or extension=openswoole.so in php.ini
serveur-openswoole.php
<?php

declare(strict_types=1);

$serveur = new OpenSwoole\WebSocket\Server('0.0.0.0', 8082);

$serveur->set([
    'worker_num' => 1,
    'log_level'  => OpenSwoole\Constant::LOG_ERROR,
]);

$serveur->on('message', function (OpenSwoole\WebSocket\Server $srv, $frame) {
    $srv->push($frame->fd, $frame->data); // echo
});

$serveur->on('close', function () {});

$serveur->start();

The code is the shortest of the three, and broadcasting to every client comes with the extension. In exchange, the extension imposes its presence on the machine and its own process model.

The client, in the browser

public/client.js
const socket = new WebSocket('wss://exemple.com/ws');

socket.addEventListener('open', () => {
  console.log('connexion ouverte');
  socket.send('Bonjour serveur');
});

socket.addEventListener('message', (e) => {
  console.log('reçu :', e.data);
});

socket.addEventListener('close', (e) => {
  console.log('fermée', e.code, e.reason);
  // reconnect with increasing back-off
});

socket.addEventListener('error', () => {
  console.error('erreur de transport');
});

In production the URL is wss://, not ws://: a browser on an HTTPS page refuses a plaintext WebSocket connection. And reconnection is not automatic, the code above has to reopen the connection when close fires, with a delay that grows on every attempt.

The measurements

The three servers implement the same echo, on the same machine, each one alone while it is being measured: a container limited to 1 core and 512 MB, PHP 8.5.10, Docker on macOS. The client is a PHP script that speaks the protocol directly.

Memory per connection

This is the most stable and the most decisive measurement. Open 500 connections, hold them, and read VmRSS for the server process before and during.

Server Idle With 500 connections Cost per connection
Ratchet 0.4.4 23,700 KB 34,940 KB 22.4 KB
Native sockets 22,880 KB 25,540 KB 5.3 KB
OpenSwoole 26.2.0 26,512 KB 26,140 KB ≈ 0 KB

The OpenSwoole figure is not a mistake: the extension allocates its connection table at start-up, which explains both the higher baseline and the flat curve afterwards. Ratchet costs four times more per connection than raw sockets, the price of the ReactPHP and PSR-7 objects stacked behind each client. At 10,000 connections, the gap between Ratchet and native sockets goes past 170 MB.

Round-trip latency

50 clients connected, 800 messages of 64 bytes sent one at a time on the first client, waiting for the echo before sending the next.

Server p50 p95 p99 Connection
Ratchet 0.4.4 0.27 ms 1.88 ms 3.90 ms 1.83 ms
Native sockets 0.03 ms 0.25 ms 0.43 ms 1.52 ms
OpenSwoole 26.2.0 0.03 ms 0.75 ms 3.02 ms 0.76 ms

All three answer in under a millisecond at the median. The gap in the tail of the distribution says more than the median does: the tail is what you feel in an interface.

Throughput: the measurement that settles nothing

50 clients in strict round-trip mode for six seconds, five passes per server, in messages exchanged per second.

Server Passes Median
Ratchet 0.4.4 25,450 · 31,667 · 32,400 · 33,933 · 36,067 32,400
Native sockets 25,258 · 27,375 · 33,425 · 35,975 · 46,417 33,425
OpenSwoole 26.2.0 22,333 · 23,175 · 25,050 · 32,317 · 34,192 25,050

The ranges overlap completely: 22,000 to 46,000 messages per second, all servers taken together, with up to 40% between two passes of the same server. This benchmark cannot rank them on throughput. The test machine, a Docker VM on macOS sharing its cores with the rest of the system, is too noisy. We prefer to say so rather than publish a ranking the measurements do not support. On a dedicated server, with a distributed load generator, the gap might well be clear, we have not measured it.

What can be stated: all three comfortably sustain tens of thousands of messages per second on a single core. For nearly every application, throughput is not the deciding criterion.

Choosing

Ratchet Native sockets OpenSwoole
Installation Composer nothing PECL, root access
Dependencies added 19 packages 0 1 extension
Complete protocol yes write it yourself yes
Memory per connection 22.4 KB 5.3 KB ≈ 0
Latest release Dec 2021 active
Deprecation notices on 8.5 3 0 0
Shared hosting no no no

In practice: OpenSwoole if you control the machine and are aiming for many simultaneous connections. Native sockets for a simple, well-bounded need, or to understand the protocol. Ratchet if you want to stay in pure Composer, accepting a library frozen since 2021, and in that case, compare it first with ReactPHP or AmPHP, which are maintained.

Before any of that: if the need is to push notifications without intensive two-way traffic, Server-Sent Events travel over ordinary HTTP, cross every proxy without configuration and reconnect on their own. They are often the right tool, and they need none of these three servers.

For plain request-response exchanges, a cURL request is still the tool for the job. On the client side, the tutorial on building an online chat in JavaScript shows the other end of the connection. The rest of the PHP tutorials are gathered on the Web development hub.

Common errors

Backslashes lost in namespaces use RatchetServerIoServer; instead of use Ratchet\Server\IoServer; produces Fatal error: Uncaught Error: Class "IoServer" not found, preceded by four warnings about use statements with no effect.
IoServer::broadcast() does not exist The method is absent from the class, verified by introspection. Broadcasting means walking the collection of connections yourself.
Exception without a leading backslash inside a namespace In a file with namespace MyApp;, writing Exception $e refers to \MyApp\Exception. You need \Throwable or \Exception.
Ratchet triggers deprecations on PHP 8.5 Three Implicitly marking parameter as nullable is deprecated notices from loading the classes alone. Last release December 2021.
One frame read per loop iteration Our first native server did not flush the pending frames: throughput collapsed because of the loop, not because of the PHP sockets.
ws:// from an HTTPS page The browser refuses a plaintext WebSocket connection from an encrypted page. In production, the URL is wss://.

OpenSwoolePHPRatchetTemps réelWebSocket

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.