Skip to main content

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:

PatternExampleUse Case
realtime:{ns}:{table}realtime:shared:postsTable subscription (static DB)
realtime:{ns}:{id}:{table}realtime:workspace:ws-456:docsTable subscription (dynamic DB)
realtime:{ns}:{table}:{docId}realtime:shared:posts:abcSingle document subscription
realtime:presence:{channel}realtime:presence:lobbyPresence tracking
realtime:broadcast:{channel}realtime:broadcast:chat-roomBroadcast 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" }
FieldRequiredDescription
typeYesMust be "auth"
tokenYesJWT access token
sdkVersionNoSDK 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​

EventDescriptiondata Field
addedNew document createdFull document data
modifiedExisting document updatedFull document data (after update)
removedDocument deletednull

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): Receive batch_changes messages, 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:

  1. Active — WebSocket is processing messages normally
  2. Hibernating — No messages for a period → DO suspends, memory freed, $0 duration billing
  3. 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​

MessageTargetPurpose
PRESENCE_RESYNCAll sockets (authenticated or not)Clients re-send their presence state
FILTER_RESYNCAuthenticated sockets onlyClients 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​

CodeDescriptionConnection Impact
AUTH_TIMEOUTNo auth message within timeout periodConnection closed
AUTH_FAILEDInvalid or expired JWT tokenConnection closed
AUTH_REFRESH_FAILEDRe-auth token invalidConnection preserved (existing auth kept)
SERVER_ERRORJWT secret not configured on serverConnection closed
NOT_AUTHENTICATEDMessage sent before completing authError response, connection preserved
INVALID_JSONUnparseable WebSocket messageError response
UNKNOWN_TYPEUnrecognized message typeError response
INVALID_CHANNELMissing channel name in subscribeError response
INVALID_FILTERSFilter validation failed (>5 conditions, bad format)Error response, subscription rejected
CHANNEL_ACCESS_DENIEDNo permission for the requested channelError response
NOT_SUBSCRIBEDFilter update on a channel not subscribed toError response
INVALID_STATEPresence state object missing or malformedError response
PRESENCE_TOO_LARGEPresence state exceeds 1KB limitError response
INVALID_EVENTBroadcast message missing event nameError response
FORCE_DISCONNECTAdmin-initiated session terminationConnection closed