Real-Time Sync
WebSocket — persistent connections for when the server needs to talk first.
You will learn
- Why HTTP can't push updates to the browser
- Register a WebSocket route with App::ws()
- Authenticate connections and broadcast to specific users
- When to use htmx vs SSE vs WebSocket vs Pub/Sub
The problem
Open your notes app in two browser tabs. Add a note in one. The other tab shows stale data until you manually refresh.
This happens because HTTP is request-response: the server only talks when the client asks. There's no way for the server to say "hey, something changed" without the client explicitly asking "did anything change?"
graph LR
subgraph "Tab A"
A1[Create note] -->|htmx POST| API
end
API -->|WS::broadcast| WS[WebSocket Server]
subgraph "Tab B"
WS -->|push| B1[note_changed]
B1 -->|htmx.ajax| B2[Refresh notes list]
B2 --> B3["Green glow ✓"]
end
subgraph "Tab A"
WS -->|push| A2[note_changed]
A2 --> A3["Already in DOM — highlight ✓"]
end
style API fill:#fffbeb,stroke:#f59e0b
style WS fill:#f5f3ff,stroke:#a855f7
style B3 fill:#ecfdf5,stroke:#059669
style A3 fill:#ecfdf5,stroke:#059669
The mental model
In Lesson 9, you used SSE to stream AI tokens. SSE is like a one-way phone call — the server talks, you listen. But SSE can't handle the case where the client needs to send messages back, or where the server needs to push updates at any time.
WebSocket is like a walkie-talkie. Both sides can talk whenever they want. The connection stays open. This is why it works for cross-tab sync: when tab A changes something, the server pushes an update to every open walkie-talkie belonging to that user.
The handler
A WebSocket route has three callbacks — same file, same framework as HTTP routes:
$app->ws('/ws/learn',
onMessage: function ($server, $frame) {
if ($frame->data === 'ping') {
$server->push($frame->fd, 'pong');
}
},
onOpen: function ($server, $request) {
$g = G::instance();
$userId = (int) ($g->session['user_id'] ?? 0);
if (!$userId) {
$server->disconnect($request->fd, 1008, 'auth_required');
return;
}
Store::set('learn_ws_clients', (string) $request->fd, [
'user_id' => $userId,
]);
},
onClose: function ($server, $fd) {
Store::del('learn_ws_clients', (string) $fd);
},
);
Broadcasting
WebSocket connections live on individual workers. To broadcast to all of a user's tabs, iterate a shared Store table that maps fd → user_id:
// src/Learn/WS.php
public static function broadcast(int $userId, array $payload): void
{
$server = App::getServer();
$json = json_encode($payload);
foreach (Store::table('learn_ws_clients') as $fd => $row) {
if ((int) $row['user_id'] === $userId) {
$server->push((int) $fd, $json);
}
}
}
Call this from any endpoint — HTTP route, SSE stream, task worker. When a note is
created or deleted, the endpoint calls WS::broadcast($userId, ['type' => 'note_changed']),
and every open tab refreshes its notes list via htmx.ajax().
Open your notes in two browser tabs. Create a note in one tab — the other tab updates instantly with a green glow. Delete a note — it fades out in both tabs. Then visit AI Chat and ask the agent to create a note: watch the Event Log show SSE tool events, then the notes panel updates via WS broadcast.
The client
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
let ws = new WebSocket(proto + '//' + location.host + '/ws/learn');
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'note_changed') {
htmx.ajax('GET', '/api/learn/notes', {
target: '#notes-list', swap: 'innerHTML'
});
}
});
ws.addEventListener('close', (ev) => {
if (ev.code === 1008) return; // auth rejected — don't retry
setTimeout(connect, Math.min(delay *= 2, 10000));
});
When to use what
| Tool | Direction | When |
|---|---|---|
| htmx | Request → response | User-initiated actions. 95% of web apps. |
| SSE | Server → client | Streaming responses: AI tokens, live logs, progress bars. |
| WebSocket | Bidirectional | Real-time sync: chat, collaborative editing, live dashboards. |
| Pub/Sub | Server → server | Multi-server broadcast. Redis or RabbitMQ when you outgrow one box. |
When you need a message broker
ZealPHP's WebSocket + Store is a single-server solution. The Store table lives in shared memory across workers on the same process. This breaks when you scale horizontally — multiple processes on different machines. A WebSocket client on server A can't receive pushes from server B.
One server? App::ws() + Store. Done.
Multiple servers? Add Redis Pub/Sub as the fan-out layer.
Durable delivery? RabbitMQ or Kafka — messages survive restarts.
Key Takeaways
- HTTP can't push — WebSocket keeps a persistent bidirectional connection
App::ws()gives you onOpen/onMessage/onClose callbacksStoretables share state across workers for broadcasting to specific users- Use htmx for user actions, SSE for streaming, WebSocket for real-time push, Pub/Sub for multi-server