Skip to main content

Realtime & Room Internals

How EdgeBase delivers real-time data subscriptions, presence tracking, broadcast messaging, and server-authoritative game/collaboration rooms.

Realtime Architecture​

EdgeBase Realtime runs entirely inside Durable Objects using the Cloudflare WebSocket Hibernation API. There is no external message broker, no pub/sub service, and no per-message billing. Idle connections cost $0.

Channel-DO Mapping​

Each Realtime channel maps to a dedicated Durable Object instance. The channel name determines the DO identity:

Channel PatternExampleUse Case
realtime:{namespace}:{table}realtime:shared:postsSubscribe to all changes on a table (static DB)
realtime:{namespace}:{instanceId}:{table}realtime:workspace:ws-456:docsSubscribe to changes in a dynamic DB instance
realtime:{namespace}:{table}:{docId}realtime:shared:posts:abc123Subscribe to a single document
realtime:presence:{channel}realtime:presence:lobbyPresence (online/offline tracking)
realtime:broadcast:{channel}realtime:broadcast:notificationsOne-shot message broadcasting

Realtime DOs do not use SQLite — all state is held in memory because it is inherently ephemeral (connection-based). When a connection closes, the associated state is expected to disappear.

WebSocket Hibernation API​

The Hibernation API is the foundation of EdgeBase Realtime's cost model:

Active connections ──── DO is awake, processing messages
│
All connections idle ──── DO hibernates ($0 duration cost)
│
Message arrives ────────── DO wakes up instantly

When a DO hibernates, its in-memory state (subscriptions, presence maps, filter registrations) is lost. EdgeBase handles this with the RESYNC protocol.

RESYNC Protocol​

When a DO wakes from hibernation, it broadcasts RESYNC messages to all connected clients:

DO wakes up (memory cleared)
│
├─ Send PRESENCE_RESYNC to all connections
│ → Clients re-send their presence state
│
└─ Send FILTER_RESYNC to authenticated connections
→ Clients re-send their subscription filters

The SDK handles RESYNC automatically — no developer intervention is needed. There is a deliberate asymmetry: PRESENCE_RESYNC goes to all connections (including unauthenticated ones), while FILTER_RESYNC only goes to authenticated connections (requiring re-auth first).

Authentication Handshake​

WebSocket connections use a message-based authentication flow rather than URL query parameters. This prevents tokens from appearing in server access logs, browser history, and Referer headers.

Client                          Server
│ │
├── WebSocket upgrade ─────────►│
│ │
├── { type: "auth", │
│ token: "eyJhbG..." } ───►│── Verify JWT
│ │
│◄── { type: "auth_success", │
│ userId: "..." } ────────│
│ │
│ (Now: subscribe, presence, │
│ broadcast operations) │
  • Authentication must complete within a timeout (default: 5000ms, configurable via realtime.authTimeoutMs)
  • Any subscribe/presence/broadcast request before authentication results in an error and connection termination
  • Authentication state is stored in WebSocket tags (Hibernation API metadata)

Keep-Alive​

Clients send { type: "ping" } every 30 seconds. The server responds with { type: "pong" }. This confirms connection liveness and resets the Hibernation idle timer.

Auto Token Refresh​

When the Access Token is refreshed (by any mechanism — HTTP request, another tab, scheduled refresh), the SDK's RealtimeClient automatically sends a re-auth message on the existing WebSocket connection:

{ "type": "auth", "token": "new-eyJhbG..." }

The server recognizes this as a re-authentication, updates the auth state, and keeps all existing subscriptions intact.

Event Propagation​

When data changes occur, the Database DO notifies the appropriate Realtime DO directly — without routing through the Worker:

Client write
│
▼
Database DO
├─ Execute SQL (INSERT/UPDATE/DELETE)
├─ Evaluate security rules
└─ stub.fetch() → Realtime DO (direct DO-to-DO call)
│
├─ Table channel: notify all table subscribers
└─ Document channel: notify single-doc subscribers

Dual Propagation​

Every CUD (Create, Update, Delete) event propagates to both the table-level channel and the document-level channel simultaneously. This ensures that subscribers watching the entire table and subscribers watching a specific document both receive real-time notifications.

Event Types​

EventTrigger
addedNew record inserted
modifiedExisting record updated
removedRecord deleted
batch_changesMultiple changes in a single transaction (above threshold)

Batch Event Bundling​

When a single transaction produces more changes than the batch threshold (default: 10, configurable via realtime.batchThreshold), events are bundled into a single batch_changes message:

{
"type": "batch_changes",
"channel": "realtime:shared:posts",
"changes": [
{ "event": "modified", "data": { "id": "...", "title": "..." } },
{ "event": "modified", "data": { "id": "...", "title": "..." } }
],
"total": 150
}

SDK version negotiation preserves protocol compatibility — older SDKs receive individual events, newer SDKs receive bundled messages.

Server-Side Subscription Filters​

Clients can register filters at subscription time to receive only matching events:

{
"type": "subscribe",
"channel": "realtime:shared:posts",
"filters": [["authorId", "==", "user-123"]],
"orFilters": [["status", "==", "published"], ["status", "==", "featured"]]
}

The filter logic translates to: WHERE authorId = ? AND (status = ? OR status = ?).

Filter TypeLogicMax Conditions
filtersAND (all must match)5
orFiltersOR (any must match)5

Filters are additive restrictions — they can only narrow what the security rules already allow, never bypass them. After hibernation wake-up, FILTER_RESYNC prompts the SDK to re-register all filters.

Dynamic Filter Updates​

Clients can update their filters without disconnecting using an update_filters message. This replaces the existing filters for a given channel subscription.

Presence​

Presence tracks which users are currently connected to a channel, along with optional metadata (e.g., cursor position, typing indicator):

// Join presence
client.realtime.presence('lobby').join({ status: 'online', name: 'Alice' });

// Listen for changes
client.realtime.presence('lobby').onJoin((userId, data) => { ... });
client.realtime.presence('lobby').onLeave((userId, data) => { ... });

Presence TTL and Auto-Cleanup​

Clients that disconnect abnormally (network failure, crash, browser close without cleanup) leave orphaned presence entries. EdgeBase handles this with a TTL-based auto-cleanup:

  • Each presence entry tracks a lastSeen timestamp
  • A DO Alarm periodically checks for stale entries
  • Entries exceeding the TTL (default: 60 seconds, configurable via realtime.presenceTTL) are automatically removed
  • A presence_leave event with reason: 'timeout' is broadcast to remaining subscribers

This eliminates ghost users without requiring explicit cleanup logic from the application.

Presence Constraints​

  • Payload size limit: 1 KB per presence entry (enforced on both server and SDK)
  • Presence state is memory-only — it does not survive hibernation. The RESYNC protocol restores it from clients.

Channel Access Control​

Channel TypeRule Source
DB subscriptions (onSnapshot)Reuses the table's read security rule (evaluated once at subscribe time)
Presence / BroadcastDefined per namespace in realtime.namespaces config
Undefined channelsAuthenticated users only (deny-by-default)
export default defineConfig({
realtime: {
namespaces: {
public: { access: { subscribe: () => true, publish: () => true } },
private: { access: { subscribe: (auth) => auth !== null } },
game: { access: { subscribe: (auth) => auth !== null, publish: (auth) => auth !== null } },
},
},
});

When a user's JWT is refreshed (re-auth), the server re-evaluates all of that user's active subscriptions. If a subscription no longer passes the rules (e.g., membership was revoked), the channel is gracefully unsubscribed and the client is notified via revokedChannels.


Room Architecture​

Room is a server-authoritative real-time state channel designed for multiplayer games, collaborative editors, and live dashboards. Unlike Realtime (which is data-driven), Room is action-driven: clients send intentions, the server decides state changes, and all clients receive the authoritative result.

Client A ── send("move", {x:5}) ──►  Room DO (onAction handler)
Client B ── send("attack", {}) ──► │
├─ Server updates state
├─ Delta broadcast to all
└─ Player-specific state unicast

Three State Areas​

State AreaVisibilityWriterPurpose
sharedStateAll connected clientsServer onlyGame world, shared document state
playerStateOnly the owning playerServer onlyHand of cards, personal inventory
serverStateServer onlyServer onlyRNG seeds, hidden game logic, timers

Clients never write state directly. They send actions via send(actionType, payload), and the server's onAction handler decides how to modify state.

Delta Broadcasting​

When state changes, Room sends only the diff (delta), not the full state:

  • shared_delta — broadcast to all connected clients
  • player_delta — unicast to the specific player only

Deltas are buffered for 50 milliseconds and throttled to 10 messages/second, reducing network overhead for rapid state changes (e.g., real-time game physics).

Zero-Cost Hibernation​

When the last player leaves a room, the DO enters hibernation:

  1. All three state areas are persisted to DO Storage
  2. The DO hibernates — $0 duration cost
  3. When a player connects again, state is restored from storage
  4. The room resumes exactly where it left off

State persistence also runs periodically (default: every 60 seconds via stateSaveInterval) to protect against crashes. A stateTTL (default: 24 hours) controls how long persisted state is kept — after expiration, the room starts fresh.

// Manual save is also available
room.saveState();

Lifecycle Hooks​

Room created
│
▼
onCreate ─── Initialize shared/server state
│
▼
Player connects
│
▼
onJoin(sender, room) ─── Validate, assign player state
│ (throw to reject)
▼
onAction[type](sender, payload, room)
│ ├─ setSharedState(data) → delta broadcast
│ ├─ setPlayerState(userId, data) → delta unicast
│ └─ setServerState(data) → server only
│
▼
Player disconnects
│
▼
onLeave(sender, room) ─── reason: 'leave' | 'disconnect' | 'kicked'
│
▼
Last player leaves
│
▼
onDestroy ─── Cleanup, final save, hibernate

Room Features​

FeatureDescription
Messagingroom.sendMessage(type, data) for broadcast; room.sendMessageTo(userId, type, data) for unicast
Broadcast Excluderoom.sendMessage(type, data, { exclude: [userId] }) to skip specific players
Kickroom.kick(userId) — triggers onLeave with reason: 'kicked'
Named Timersroom.setTimer(name, ms, data?) / room.clearTimer(name) — persisted across hibernation
Metadataroom.setMetadata(data) — queryable via HTTP without WebSocket (useful for lobbies)
Admin Contextctx.admin is injected into handlers for DB access from within room logic
State Size WarningROOM_STATE_WARNING event fires when cumulative state reaches 80% of maxStateSize

Room Configuration Defaults​

SettingDefaultDescription
reconnectTimeout30 secondsHow long to hold a player's slot after disconnect
rateLimit.actions10 (token bucket)Max actions per second per player
maxStateSize1 MBMaximum cumulative state across all three areas
stateSaveInterval60 secondsHow often state is persisted to DO Storage
stateTTL24 hoursHow long persisted state is retained
Action timeout5 secondsMax execution time per onAction handler
Delta buffer50 msDelta batching window

Player Information Security​

The server does not automatically expose the player list to clients. To make player information visible, the developer must explicitly share it through setSharedState in the onJoin and onLeave handlers. This prevents unintended leaking of connection metadata.

Next Steps​

  • Cost Analysis — Why Realtime and Room cost ~300x less than alternatives
  • Security Model — Channel access control and membership verification