---
title: Real-time WebSocket
path: advanced/realtime-websocket
status: published
---

Two WebSocket endpoints stream real-time updates: one scoped to a sync device, one general-purpose for UIs. This page covers both in detail.

## Endpoints

| Endpoint | Purpose |
|----------|---------|
| `WS /api/v1/sync/ws/{device_id}` | Sync nudges for a registered device |
| `WS /api/v1/realtime/ws` | General updates for the authenticated user |

Both use WSS (TLS) in production.

## Authentication

WebSocket handshakes can't carry arbitrary headers from browsers, so ScaiDrive accepts the JWT as a query parameter:

```text
wss://scaidrive.scailabs.ai/api/v1/realtime/ws?token=eyJhbGc...
```

Non-browser clients can also pass `Authorization: Bearer <token>` as a header during the upgrade; both methods are honored.

If the token is missing or invalid, the server closes the connection with code `4401`.

## Sync WebSocket

Used by clients that replicate content locally. Connects per-device.

```python
import asyncio, json
import websockets

async def run(url_ws, token, device_id):
    url = f"{url_ws}/api/v1/sync/ws/{device_id}?token={token}"
    async with websockets.connect(url) as ws:
        await ws.send(json.dumps({"type": "subscribe"}))
        async for msg in ws:
            frame = json.loads(msg)
            handle(frame)

def handle(frame):
    t = frame.get("event_type") or frame.get("type")
    if t == "change":
        trigger_pull(frame["share_id"])
    elif t == "force_sync":
        trigger_pull(frame["share_id"], full=True)
    elif t == "sync_preference_updated":
        reload_preferences(frame["share_id"])
    elif t == "pong":
        pass
```

```typescript
const ws = new WebSocket(
  `${urlWs}/api/v1/sync/ws/${deviceId}?token=${token}`,
);

ws.addEventListener("open", () => {
  ws.send(JSON.stringify({ type: "subscribe" }));
});

ws.addEventListener("message", (e) => {
  const frame = JSON.parse(e.data);
  switch (frame.event_type ?? frame.type) {
    case "change": triggerPull(frame.share_id); break;
    case "force_sync": triggerPull(frame.share_id, true); break;
    case "sync_preference_updated": reloadPrefs(frame.share_id); break;
  }
});
```

### Server → client frames

| Frame | Payload |
|-------|---------|
| `{"event_type": "change", "share_id": "...", "data": {...}}` | A change occurred on a share the device is subscribed to |
| `{"event_type": "sync_preference_updated", "share_id": "...", "device_id": "..."}` | Preferences changed (possibly from another device or admin) |
| `{"event_type": "force_sync", "share_id": "..."}` | Server requests a full resync |
| `{"type": "pong"}` | Response to ping |
| `{"type": "subscribed"}` | Subscription confirmed |

The `change` frame's `data` is the full change entry (see [Sync Model](/docs/scaidrive/core-concepts/sync-model#the-sync-loop)) so clients can often act without a pull. For reliability, clients should still pull the change stream to close any gaps.

### Client → server frames

| Frame | Effect |
|-------|--------|
| `{"type": "ping"}` | Heartbeat; expect `pong` |
| `{"type": "subscribe"}` | Subscribe to all shares the authenticated user has access to |

The sync WebSocket does not require explicit per-share subscription — subscribing once covers every share the device syncs.

## Realtime WebSocket (UIs)

Used by the web and mobile clients for live UI updates. Requires explicit per-share subscription.

```javascript
const ws = new WebSocket(
  `wss://scaidrive.scailabs.ai/api/v1/realtime/ws?token=${token}`,
);

ws.addEventListener("open", () => {
  ws.send(JSON.stringify({ action: "subscribe", share_id: "shr_01J3K" }));
});

ws.addEventListener("message", (e) => {
  const f = JSON.parse(e.data);
  switch (f.type) {
    case "file_created": onCreated(f.share_id, f.data); break;
    case "file_updated": onUpdated(f.share_id, f.data); break;
    case "file_deleted": onDeleted(f.share_id, f.data.file_id); break;
    case "folder_created": onFolder(f.share_id, f.data); break;
    case "folder_deleted": onFolderDeleted(f.share_id, f.data.folder_id); break;
  }
});
```

### Server → client frames

| Frame | Payload |
|-------|---------|
| `connected` | `{user_id, timestamp}` — sent once on handshake |
| `subscribed` | `{share_id}` — after subscribe |
| `unsubscribed` | `{share_id}` — after unsubscribe |
| `file_created` | `{share_id, data: file metadata}` |
| `file_updated` | `{share_id, data: file metadata}` |
| `file_deleted` | `{share_id, data: {file_id}}` |
| `folder_created` | `{share_id, data: folder metadata}` |
| `folder_deleted` | `{share_id, data: {folder_id}}` |
| `pong` | Response to ping |

### Client → server actions

| Action | Effect |
|--------|--------|
| `{"action": "subscribe", "share_id": "..."}` | Subscribe to a share |
| `{"action": "unsubscribe", "share_id": "..."}` | Unsubscribe |
| `{"action": "ping"}` | Heartbeat |

## Heartbeats

Send a ping every 30 seconds. If you don't receive a pong within 30 seconds, assume the connection is dead and reconnect.

Most proxy layers (nginx, cloud load balancers) idle out WebSockets after 60 seconds of silence. The heartbeat keeps the connection open.

## Reconnection

WebSockets drop. Plan for it:

1. On `close` event, wait with exponential backoff: 1s, 2s, 4s, 8s, max 30s.
2. Reconnect. The server doesn't persist per-connection state; you re-subscribe and the server re-delivers a subscription confirmation.
3. After reconnecting, **pull the change stream with your current cursor** — don't assume the WebSocket delivered every frame while you were disconnected.

The change log is the source of truth. The WebSocket is a low-latency hint.

## Back-pressure

For slow clients, the server buffers a limited number of frames (default 1000 per connection). If the buffer fills, the server sends a `force_sync` frame and may close the connection. Clients should drain messages promptly, or batch UI updates.

## Scaling

Real-time WebSocket subscriptions go through Redis pub/sub — one ScaiDrive instance can serve many connections, and horizontal scaling is fan-out through Redis. No sticky sessions are required; any instance can handle any WebSocket.

## Stats

For admin / observability:

```bash
curl -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
     $SCAIDRIVE_URL/api/v1/realtime/stats
```

```json
{
  "total_connections": 423,
  "subscribed_shares": 87,
  "subscriptions_by_share": {
    "shr_01J3K": 42,
    "shr_01J4Z": 18
  }
}
```

## What's next

- [Sync Model](/docs/scaidrive/core-concepts/sync-model)
- [Sync Guide](/docs/scaidrive/api-guides/sync)
- [Events and Real-time Updates](/docs/scaidrive/core-concepts/events-and-realtime)