WebSocket Real-Time
Real-time WebSocket support with Socket.IO
Arcway supports real-time updates over WebSocket using Socket.IO. Add a ws() function to any GET route to make it real-time — clients using useApi() automatically subscribe and receive live updates.
How It Works
- Arcway manages a single WebSocket connection per client at
/ws(configurable) - Routes with a
ws()function on their GET export become real-time useApi()auto-subscribes when the provider has awsUrlconfigured- Mutations (
post,put,del) go over WebSocket when connected, with HTTP fallback
No new concepts to learn. You write REST handlers — adding ws() makes them real-time.
Backend: Adding Real-Time to a Route
Any route can become real-time by adding an async ws() function to its GET export:
// api/chat/rooms/[id].js
export const GET = {
handler: async (ctx) => {
const messages = await ctx.db('messages').where('room_id', ctx.req.query.id);
return { data: messages };
},
// Presence of ws() enables WebSocket subscriptions on this route.
// Called when a client subscribes. Return a cleanup function (like useEffect).
async ws(ctx) {
// Optional: set up event listeners, timers, etc.
// The cleanup function runs when the client unsubscribes or disconnects.
},
};
export const POST = {
handler: async (ctx) => {
const { text } = ctx.req.body;
const [id] = await ctx.db('messages').insert({ room_id: ctx.req.query.id, text });
const message = await ctx.db('messages').where('id', id).first();
return { status: 201, data: message };
// Arcway automatically broadcasts this response to all other subscribers
},
};The ws() Function
- Receives: A
ctxobject withreq(same as other handlers —req.query,req.session,req.headers, etc., plusreq.socketIdidentifying the WebSocket connection) and infrastructure services (db,log,events, etc.) - Returns: An optional cleanup function called when the client unsubscribes or disconnects
- Lifecycle:
ws()runs first, then Arcway automatically callshandler()and sends the initial data to the subscriber
Automatic Broadcasting
When a client sends a mutation (POST, PUT, PATCH, DELETE) over WebSocket, Arcway automatically broadcasts the handler's response to all other clients subscribed to the same path. No extra code needed — just return { data } from your handler and all subscribers get the update.
Server-Initiated Push (Advanced)
For cases where updates originate from the server (not from a client request), Arcway provides two helper functions:
import { wsBroadcastToPath, wsSendToSocket } from 'arcway';
// Push to ALL subscribers of a path (e.g. from a cron job or event listener)
wsBroadcastToPath('/chat/rooms/123', { data: updatedMessages });
// Push to ONE specific client (e.g. notify a user their background job finished)
wsSendToSocket(ctx.req.socketId, '/chat/rooms/123', { data: personalNotification });These are optional — most apps only need the automatic handler-response broadcasting.
Frontend: Using Real-Time Routes
Provider Setup
Add wsUrl to your Provider:
import { Provider } from 'arcway/lib/client';
function App() {
return (
<Provider pathPrefix="/api" wsUrl="ws://localhost:3000/ws">
<ChatRoom roomId="123" />
</Provider>
);
}useApi with Real-Time
useApi automatically subscribes via WebSocket when the route supports it:
import { useApi } from 'arcway/lib/client';
function ChatRoom({ roomId }) {
// Auto-subscribes to /api/chat/rooms/123 via WebSocket
// data updates in real-time when the server pushes changes
const { data, post, del, loading } = useApi(`/chat/rooms/${roomId}`);
// Mutations go over WebSocket (falls back to HTTP if disconnected)
const sendMessage = () => post({ text: 'hello' });
const deleteMessage = (id) => del({ messageId: id });
if (loading) return <p>Loading...</p>;
return (
<div>
{data?.map(msg => <p key={msg.id}>{msg.text}</p>)}
<button onClick={sendMessage}>Send</button>
</div>
);
}Disabling WebSocket
Disable WebSocket for a specific hook:
// This hook uses HTTP only, no WebSocket subscription
const { data } = useApi('/stats', undefined, { ws: false });Mutation Methods
useApi returns mutation helpers that route over WebSocket when available:
| Method | HTTP equivalent |
|---|---|
post(body) | POST |
put(body) | PUT |
patch(body) | PATCH |
del(body) | DELETE |
All return promises and throw ApiError on failure — same behavior as HTTP requests.
Wire Protocol
The WebSocket uses Socket.IO with JSON messages:
Client → Server (via msg event):
{ "path": "/chat/rooms/123", "method": "SUBSCRIBE" }
{ "path": "/chat/rooms/123", "method": "UNSUBSCRIBE" }
{ "path": "/chat/rooms/123", "method": "POST", "body": { "text": "hello" }, "id": "1" }
{ "path": "/chat/rooms/123", "method": "GET", "id": "2" }Server → Client (via msg event):
{ "path": "/ws", "data": { "socketId": "uuid" }, "status": 200 }
{ "path": "/chat/rooms/123", "data": [...], "id": "1" }
{ "path": "/chat/rooms/123", "error": { "code": "NOT_FOUND", "message": "..." }, "status": 404 }Methods: GET, POST, PUT, PATCH, DELETE, SUBSCRIBE, UNSUBSCRIBE.
The id field is optional — used to correlate request/response pairs.
Configuration
WebSocket is automatically enabled when any route has a ws() function. No configuration needed.
// arcway.config.js
export default {
websocket: {
enabled: false, // Disable WebSocket entirely (default: true when routes have ws())
path: '/realtime', // Custom endpoint path (default: '/ws')
pingIntervalMs: 30000, // Ping interval for dead client detection (default: 30s, 0 to disable)
driver: 'memory', // 'memory' (default) | 'redis' — see "Cluster mode" below
},
};Cluster Mode
By default, wsBroadcastToPath and wsSendToSocket only reach clients connected to the same worker process. In a multi-process deployment (PM2 cluster, Node cluster module), each worker has its own in-memory registry, so a broadcast from worker A won't reach clients on worker B.
To fix this, set websocket.driver: 'redis'. Arcway will use a Redis pub/sub channel as a backplane — every broadcast or targeted send is published to Redis and forwarded to all workers, which deliver it to their local clients.
Enabling the Redis backplane
// arcway.config.js
export default {
redis: {
url: process.env.REDIS_URL, // e.g. redis://localhost:6379
},
websocket: {
driver: 'redis', // enable cross-worker broadcasts
},
};That's it. The wsBroadcastToPath and wsSendToSocket APIs are unchanged — no application code needs to change.
What happens without the backplane
| Scenario | Behaviour |
|---|---|
driver: 'memory' (default), single process | Full delivery — all subscribers reached |
driver: 'memory', multiple workers detected | Warning logged at boot; broadcasts only reach clients on the same worker |
driver: 'redis', Redis available | Full cross-worker delivery via pub/sub backplane |
driver: 'redis', no Redis configured or unreachable | Warning logged at boot; falls back to local fan-out (no crash) |
Arcway detects a likely cluster environment by checking process.env.pm_id (PM2), process.env.NODE_APP_INSTANCE (PM2/other managers), and cluster.isWorker (Node cluster). It warns you at startup if you're running in cluster mode with driver: 'memory'.
Self-filter
When using the Redis backplane, each worker tags its published messages with a unique worker ID. Workers ignore messages they published themselves, preventing double-delivery to local subscribers.
PM2 cluster example
// ecosystem.config.js
module.exports = {
apps: [{
name: 'myapp',
script: 'npx',
args: 'arcway start',
instances: 4, // 4 workers — broadcasts need driver: 'redis'
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
REDIS_URL: 'redis://localhost:6379',
},
}],
};// arcway.config.js
export default {
redis: { url: process.env.REDIS_URL },
websocket: { driver: 'redis' },
};Dead Client Detection
Arcway pings all connected clients at the configured interval (default: 30 seconds). Clients that don't respond with a pong are terminated. Socket.IO handles pong responses automatically — no client-side code needed.
Auto-Reconnection
The client-side WsManager automatically reconnects with exponential backoff (1s, 2s, 4s, ... up to 30s). After reconnecting, it re-subscribes to all active subscriptions. No manual handling needed — useApi hooks seamlessly resume receiving updates.
How It Fits Together
┌─────────────────────────────────────────────────────┐
│ Client │
│ │
│ useApi('/chat/rooms/123') │
│ ├── GET /chat/rooms/123 (HTTP, initial fetch) │
│ ├── SUBSCRIBE /chat/rooms/123 (WebSocket) │
│ │ └── receives real-time data pushes │
│ ├── post({text}) → POST over WS (or HTTP) │
│ └── unmount → UNSUBSCRIBE (WebSocket) │
│ │
├─────────────────────────────────────────────────────┤
│ Arcway Server │
│ │
│ /ws endpoint (single WebSocket per client) │
│ ├── SUBSCRIBE → ws(ctx) → handler(ctx) │
│ ├── UNSUBSCRIBE → cleanup function │
│ ├── POST/PUT/DELETE → route to handler │
│ └── broadcast/send → push to subscribers │
│ │
│ api/chat/rooms/[id].js │
│ ├── GET.handler(ctx) → { data } │
│ ├── GET.ws(ctx) → setup + cleanup │
│ └── POST.handler(ctx) → { data } + broadcast │
└─────────────────────────────────────────────────────┘