Real-Time Sync
Open this page in two tabs. Click +1 in one. The other tab counts up too.
You will learn
- Why HTTP can't push, and what WebSocket changes
- The 3-callback shape: onOpen, onMessage, onClose
- Broadcasting to many connected clients at once
- When to use WebSocket vs SSE vs htmx vs Redis Pub/Sub
1. Overview — what you’re building
A counter that updates in every open tab the moment any tab clicks +1. No
reloads, no polling, no Redis on a single server (cross-server deploys use the Redis-backed federated routing the framework ships — see the Pub/Sub bridge below). The server holds a list of open WebSocket connections; the
+1 button hits a normal HTTP endpoint; that endpoint broadcasts the new value
back over WebSocket to every connected tab. Try it now — open this URL in another tab first.
route/ws-counter.php—Store::make()call + WebSocket endpoint + broadcast helper + HTTP bump route
use ZealPHP\{App, Store, Counter}; at the top of the file.
Call Store::make('ws_clients', 4096, ['connected_at' => [Store::TYPE_INT, 8]])
before $app->run() — the table must be allocated in the master process so all
workers share it. Without this call, Store::set() silently does nothing and
Store::table() returns null, so the broadcast loop’s
foreach raises a Warning and pushes to nobody.
Restart the server after creating the route file.
connected clients see this value update in real time
Open /learn/websocket in a second tab. Click +1 here. Watch the second tab count up without reloading.
2. Server setup — why HTTP can’t push
HTTP is request/response. Client asks, server answers, connection closes. The server cannot "speak first" because there’s no open socket waiting. To get a new value to a browser with plain HTTP, you have to ask repeatedly (polling) — burning bandwidth and worker-time even when nothing changed.
WebSocket upgrades the HTTP connection to a long-lived bidirectional channel. The server can push at any time. The client can send at any time. The same TCP socket carries both directions. ZealPHP’s OpenSwoole engine handles thousands of concurrent WebSocket connections per worker — coroutines (Lesson 24, Async Patterns covers them) mean each one costs ~5 KB of memory and zero worker-time while idle.
The four callbacks
sequenceDiagram
participant B as Browser
participant SW as OpenSwoole
Server
participant H as Your callbacks
participant ST as Store
ws_clients table
B->>SW: HTTP Upgrade request
SW->>H: onOpen(server, request)
H->>ST: Store fd
H-->>B: push(fd, current value)
Note over B,SW: Connection is now open,
bidirectional channel
B->>SW: ws.send(ping)
SW->>H: onMessage(server, frame)
H-->>B: push(fd, pong)
Note over B: ... time passes ...
B->>SW: close / disconnect
SW->>H: onClose(server, fd)
H->>ST: Delete fd
One call to $app->ws(), three callbacks. The callbacks handle the connection
lifecycle:
use ZealPHP\{App, Store, Counter};
// Create the shared table ONCE, before $app->run() — allocated in the master
// process and shared across all workers on fork.
Store::make('ws_clients', 4096, ['connected_at' => [Store::TYPE_INT, 8]]);
// Create the shared counter ONCE, before $app->run() — it lives across all
// workers and the closures below capture it via use().
$counter = new Counter(0);
$app->ws('/ws/counter-demo',
onMessage: function ($server, $frame) {
// Client sent a frame. $frame->data is the payload.
if ($frame->data === 'ping') $server->push($frame->fd, 'pong');
},
onOpen: function ($server, $request) use ($counter) {
// New connection — store the fd so we can push to it later.
Store::set('ws_clients', (string)$request->fd, ['connected_at' => time()]);
// Send the current value to this new tab so it’s in sync immediately.
$server->push($request->fd, json_encode(['value' => $counter->get()]));
},
onClose: function ($server, $fd) {
// Client disconnected — forget the fd.
Store::del('ws_clients', (string)$fd);
},
);
$server is the OpenSwoole WebSocket server (same object across all callbacks).
$request->fd in onOpen is the new socket’s file descriptor —
an integer that’s your handle to that specific client until they disconnect. Storing
every fd in a Store table is how you keep track of "who’s open" for
broadcasting.
3. Broadcast patterns
To push a message to every connected client, walk your fd table and call
$server->push() on each one:
// src/Broadcasts.php — helpers live in a src/ class, route files stay thin
namespace App;
use ZealPHP\{App, Store};
final class Broadcasts {
public static function counter(int $value): void {
$server = App::getServer();
$payload = json_encode(['value' => $value]);
foreach (Store::table('ws_clients') as $fd => $_) {
$fd = (int)$fd;
if ($server->isEstablished($fd)) $server->push($fd, $payload);
}
}
}
The broadcaster is a public static function on a small src/ class
(autoloaded via PSR-4), not a top-level function in the route file — route files stay
thin and hot-reloadable. The call site qualifies it as Broadcasts::counter(...):
use App\Broadcasts; // helpers live in a src/ class — route files stay thin
// In the +1 endpoint:
$app->route('/api/counter/bump', ['methods' => ['POST']], function () use ($counter) {
$new = $counter->increment();
Broadcasts::counter($new);
return ['value' => $new];
});
isEstablished($fd) guards against the race where a client disconnected but their
fd is still in your table because onClose hasn’t finished cleaning up.
Pushing to a dead fd raises a warning; isEstablished() avoids it.
4. Client lifecycle
In the browser, WebSocket is a one-liner:
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(proto + '//' + location.host + '/ws/counter-demo');
ws.onopen = () => console.log('connected');
ws.onmessage = e => {
const msg = JSON.parse(e.data);
document.getElementById('count').textContent = msg.value;
};
ws.onclose = () => console.log('disconnected — reconnect after a backoff if you want');
ws.send('ping'); // round-trip test
The same ws.send() you can call any time — and the server’s
onMessage picks it up. The browser’s WebSocket API has been
stable since 2011; every major browser ships it. No library needed.
When to use what
WebSocket isn’t the answer to "make the UI update." Pick by data shape:
| Pattern | Direction | Best for |
|---|---|---|
| htmx | Client → server (request/response) | Form posts, click handlers, search-as-you-type. The default. |
| SSE (streaming) | Server → client (one-way push) | AI tokens streaming, log tails, progress bars, notifications. |
| WebSocket | Both ways, low latency | Chat, multiplayer state, collaborative editing, anything where the client also sends. |
| Redis Pub/Sub | Server ↔ server fan-out | Multi-server deployments where one box's broadcast needs to reach clients on another box. |
Rule of thumb: SSE for push-only, WebSocket for two-way. Reach for WebSocket
only when the client also needs to send back. Don’t use WebSocket for one-way push —
SSE is lighter (auto-reconnect built into EventSource, works through every proxy
that handles long-lived HTTP, no upgrade handshake).
Scaling past one server: Pub/Sub bridge
Everything above lives in one process. Two ZealPHP servers behind a load balancer don’t
share their ws_clients tables — a broadcast in process A doesn’t
reach clients connected to process B. The fix is a shared bus that both processes subscribe
to. ZealPHP v0.2.39 ships this as a first-class primitive: Store::publish +
App::subscribe on the Redis backend.
// app.php — flip Store to Redis once. Counter follows automatically.
Store::defaultBackend(Store::BACKEND_REDIS);
// Each ZealPHP process registers a subscriber at boot. ZealPHP spawns the
// dedicated subscriber coroutine in onWorkerStart for you; handlers run in
// go() per message so a slow handler can't block the next read.
App::subscribe('counter:bump', function (string $payload) {
$data = json_decode($payload, true);
Broadcasts::counter((int) $data['value']); // same src/ class helper
});
// In the +1 endpoint — publish instead of broadcasting directly.
$app->route('/api/counter/bump', ['methods' => ['POST']], function () use ($counter) {
$new = $counter->increment();
Store::publish('counter:bump', json_encode(['value' => $new]));
return ['value' => $new];
});
Now process B’s subscriber sees the message and broadcasts to its own local
ws_clients. Every connected tab sees the update, no matter which process is
holding their socket. Same idea works for chat fan-out, presence, any cross-process event.
Point-to-point routing (rather than broadcast): store
client_id → server_id in the same Redis-backed Store. Each server subscribes to
its identity channel (ws:server:{ID}). To message client X:
// Anywhere — message a specific client by id.
$owner = Store::get('client_locations', $clientId, 'server');
Store::publish("ws:server:$owner", json_encode([
'client_id' => $clientId,
'data' => $payload,
]));
// Each server's subscriber routes to the local fd.
App::subscribe("ws:server:{$myServerId}", function (string $payload) use ($server, $fdMap) {
$msg = json_decode($payload, true);
$fd = $fdMap[$msg['client_id']] ?? null;
if ($fd !== null && $server->isEstablished($fd)) {
$server->push($fd, $msg['data']);
}
});
ZealPHP’s WebSocket fd is process-local — only the owning server can push to it.
Redis is the routing fabric that says “hey, owner: here’s something to push.”
Sub-millisecond loopback, ~ms cross-region. See
/store#pubsub for the at-least-once publishReliable
variant (Redis Streams) when drops aren’t acceptable.
You want every connected client to see live notifications. The client never sends anything back to the server. Which primitive fits best?
Key Takeaways
- HTTP is request/response; WebSocket upgrades it to bidirectional long-lived.
$app->ws($path, onMessage, onOpen, onClose)— three callbacks handle the lifecycle.- Track open fds in
Storeso you can broadcast;isEstablished($fd)guards against races. - SSE for push-only, WebSocket for two-way. Don't use WebSocket when SSE would do.
- Scale past one server with
Store::publish+App::subscribeon the Redis backend — each process subscribes and re-broadcasts to its localws_clients.Store::publishReliablefor at-least-once via Redis Streams.