Architecture
EdgeBase Realtime is built on Cloudflare Durable Objects with the WebSocket Hibernation API. Each channel maps to a single DO instance — idle connections cost $0 and wake instantly when new data arrives.
Channel-DO Mapping
Every realtime channel maps to one RealtimeDO instance via idFromName(). The channel name determines the DO identity:
| Pattern | Example | Use Case |
|---|---|---|
realtime:{ns}:{table} | realtime:shared:posts | Table subscription (static DB) |
realtime:{ns}:{id}:{table} | realtime:workspace:ws-456:docs | Table subscription (dynamic DB) |
realtime:{ns}:{table}:{docId} | realtime:shared:posts:abc | Single document subscription |
realtime:presence:{channel} | realtime:presence:lobby | Presence tracking |
realtime:broadcast:{channel} | realtime:broadcast:chat-room | Broadcast messaging |
- No SQLite — RealtimeDO is purely memory-based. Presence state exists only while connections are active.
- Channel = DO instance — Each unique channel name creates a separate DO, providing natural isolation and horizontal scaling.
WebSocket Connection Flow
Client → HTTPS GET /api/realtime?channel=X
│
▼
Worker (Hono Router)
│ 1. DDoS defense: check IP pending count (max 5, TTL 10s)
│ 2. Resolve DO: REALTIME.idFromName(channel)
│ 3. Forward WebSocket upgrade to DO
▼
RealtimeDO
│ 4. Accept WebSocket, start auth timeout (5000ms)
│ 5. Wait for auth message: { type: 'auth', token: '...' }
│ 6. Verify JWT → auth_success or auth_error
▼
Authenticated Connection — ready for subscribe/presence/broadcast
DDoS Defense
The Worker layer implements IP-based rate limiting to prevent unauthenticated WebSocket flood attacks:
- Max 5 pending connections per IP address
- TTL 10 seconds — counter auto-expires, no cleanup needed
- KV-based counter: +1 on connect, -1 on auth success
- Exceeding the limit returns HTTP
429 Too Many Requests - The global rate limiter (10,000,000 req/60s) acts as a final safety net
Authentication Handshake
After WebSocket upgrade, the first message must be an authentication message:
// Step 1: Client → Server
{ "type": "auth", "token": "eyJhbG...", "sdkVersion": "0.1.0" }
// Step 2a: Server → Client (success)
{ "type": "auth_success", "userId": "user_123" }
// Step 2b: Server → Client (failure — connection closed)
{ "type": "auth_error", "message": "Invalid or expired token" }
| Field | Required | Description |
|---|---|---|
type | Yes | Must be "auth" |
token | Yes | JWT access token |
sdkVersion | No | SDK version for feature negotiation (e.g., batch support) |
Why message-based authentication? Sending the token via URL query parameter (?token=...) would expose it in server access logs, browser history, and Referer headers. The Sec-WebSocket-Protocol header was considered but has incomplete browser support. Message-based auth keeps the token out of all HTTP-visible channels.
Token Refresh
Already-authenticated connections can send a new auth message at any time:
// Client → Server (refresh)
{ "type": "auth", "token": "new-eyJhbG..." }
// Server → Client (success — includes revoked channels if any)
{ "type": "auth_refreshed", "userId": "user_123", "revokedChannels": ["realtime:shared:secret-docs"] }
// Server → Client (failure — existing auth preserved, non-fatal)
{ "type": "error", "code": "AUTH_REFRESH_FAILED", "message": "Token refresh failed — existing auth preserved" }
On successful refresh, the server re-evaluates all subscribed channels. See Access Rules — Permission Re-evaluation.
Event Propagation
When data changes in a Database DO, the event is propagated to subscribers:
Client B → insert(post) → DatabaseDO
│
│ stub.fetch() — direct DO-to-DO call (no Worker hop)
▼
RealtimeDO (table channel: realtime:shared:posts)
│ Evaluate server-side filters per subscriber
│ Broadcast to matching subscribers
▼
Client A ← { type: 'added', data: {...} }
RealtimeDO (document channel: realtime:shared:posts:post-id)
│ Broadcast to document subscribers
▼
Client C ← { type: 'added', data: {...} }
Dual Propagation
Every CUD (Create/Update/Delete) event is broadcast to both the table channel and the document channel simultaneously:
- Table channel (
realtime:shared:posts) — receives all changes for the entire table - Document channel (
realtime:shared:posts:post-id) — receives changes only for that specific document
This means table-level subscribers and document-level subscribers both get notified without any extra work.
Event Types
| Event | Description | data Field |
|---|---|---|
added | New document created | Full document data |
modified | Existing document updated | Full document data (after update) |
removed | Document deleted | null |
Batch Event Delivery
When a single transaction produces many changes (above the batchThreshold, default 10), the server bundles them into a single batch_changes message:
{
"type": "batch_changes",
"channel": "realtime:shared:posts",
"changes": [
{ "event": "modified", "docId": "post_1", "data": { "title": "Updated" } },
{ "event": "modified", "docId": "post_2", "data": { "title": "Also updated" } }
],
"total": 150
}
SDK Version Negotiation
The server uses the sdkVersion sent during authentication to determine batch support:
- Modern SDKs (with
sdkVersion): Receivebatch_changesmessages, unpacked by the SDK automatically - Legacy SDKs (no
sdkVersion): Receive individual events one at a time (backward compatible)
Hibernation & Recovery
Cloudflare's Hibernation API allows idle WebSocket connections to be suspended with zero cost:
- Active — WebSocket is processing messages normally
- Hibernating — No messages for a period → DO suspends, memory freed, $0 duration billing
- Waking — New message arrives → DO wakes, memory is empty, connections are preserved
On wake-up, all in-memory state (presence maps, filter registrations, metadata cache) is lost. The RESYNC protocol handles recovery:
RESYNC Protocol
| Message | Target | Purpose |
|---|---|---|
PRESENCE_RESYNC | All sockets (authenticated or not) | Clients re-send their presence state |
FILTER_RESYNC | Authenticated sockets only | Clients re-register their filter conditions |
Why the asymmetry? Unauthenticated clients cannot have active filters (filters require a subscription, which requires auth), but they may need to know the DO has woken up to initiate re-authentication. PRESENCE_RESYNC goes to all sockets to give every client a chance to re-auth and re-track.
The SDK handles RESYNC transparently — your callbacks continue working without interruption.
Error Codes
| Code | Description | Connection Impact |
|---|---|---|
AUTH_TIMEOUT | No auth message within timeout period | Connection closed |
AUTH_FAILED | Invalid or expired JWT token | Connection closed |
AUTH_REFRESH_FAILED | Re-auth token invalid | Connection preserved (existing auth kept) |
SERVER_ERROR | JWT secret not configured on server | Connection closed |
NOT_AUTHENTICATED | Message sent before completing auth | Error response, connection preserved |
INVALID_JSON | Unparseable WebSocket message | Error response |
UNKNOWN_TYPE | Unrecognized message type | Error response |
INVALID_CHANNEL | Missing channel name in subscribe | Error response |
INVALID_FILTERS | Filter validation failed (>5 conditions, bad format) | Error response, subscription rejected |
CHANNEL_ACCESS_DENIED | No permission for the requested channel | Error response |
NOT_SUBSCRIBED | Filter update on a channel not subscribed to | Error response |
INVALID_STATE | Presence state object missing or malformed | Error response |
PRESENCE_TOO_LARGE | Presence state exceeds 1KB limit | Error response |
INVALID_EVENT | Broadcast message missing event name | Error response |
FORCE_DISCONNECT | Admin-initiated session termination | Connection closed |