Real-time Streaming
EdgeFlags provides two WebSocket endpoints for real-time data:
| Endpoint | Purpose | Permission |
|---|---|---|
/stream/flags | Receive flag/config updates as they happen | read:flags |
/stream/tail | Stream individual evaluation events (admin live tail) | * (admin) |
Both endpoints authenticate via ?token= query parameter or Authorization: Bearer header.
Flag sync (/stream/flags)
The flag sync stream delivers flag and config changes to SDK clients in real-time, replacing polling with sub-50ms push updates.
Connection
const ws = new WebSocket('wss://edgeflags.net/stream/flags?token=ff_prod_abc123');Client messages
After connecting, send a subscribe message to start receiving updates:
{ "type": "subscribe", "env": "production", "context": { "user_id": "u_42", "plan": "premium", "custom": { "country": "US" } }}| Message | Description |
|---|---|
subscribe | Start receiving updates for the given environment and context |
update-context | Change the evaluation context (triggers a new snapshot) |
ping | Keep-alive; server responds with pong |
Server messages
| Message | Description |
|---|---|
snapshot | Full set of evaluated flags and configs |
diff | Incremental changes since the last snapshot |
pong | Response to ping |
error | Error with message and code fields |
Snapshot
Sent after subscribe or update-context:
{ "type": "snapshot", "flags": { "dark_mode": true, "new_checkout": false }, "configs": { "api_limits": { "requests_per_minute": 500 } }}Diff
Sent when a flag or config changes on the server:
{ "type": "diff", "changes": [ { "kind": "flag", "key": "dark_mode", "value": false }, { "kind": "config", "key": "api_limits", "value": { "requests_per_minute": 1000 } } ]}SDK integration
The @edgeflags/sdk supports WebSocket mode. When the server has the FLAG_SYNC binding configured, the SDK can use streaming instead of polling:
const ef = new EdgeFlags({ token: 'ff_prod_abc123', baseUrl: 'https://edgeflags.net', context: { user_id: 'u_42', custom: {} }, streaming: true, // Use WebSocket instead of polling});
await ef.init();When streaming: true, the SDK opens a WebSocket to /stream/flags, subscribes with the current context, and applies diffs as they arrive. The change event fires on each diff, and useFlag/useConfig hooks re-render automatically.
Live evaluation tail (/stream/tail)
The live tail stream broadcasts individual flag evaluation events in real-time. This powers the Live Tail tab in the admin dashboard’s Observability page.
Connection
const ws = new WebSocket('wss://edgeflags.net/stream/tail?token=ff_prod_admin_token');Client messages
| Message | Fields | Description |
|---|---|---|
start | filters? | Begin streaming; optionally filter by flag keys or reasons |
filter | filters | Update active filters without reconnecting |
pause | — | Temporarily stop receiving events |
resume | — | Resume after pause |
Filters
{ "type": "start", "filters": { "flags": ["dark_mode", "new_checkout"], "reasons": ["targeting", "rollout"] }}Both filter fields are optional. When set, only matching events are delivered. Filter values:
flags— array of flag key stringsreasons— array of evaluation reasons:disabled,override,targeting,rollout,default,environment_override
Server messages
| Message | Description |
|---|---|
backfill | Last 50 matching events from the ring buffer (sent on connect) |
eval | Individual evaluation event |
stats | Periodic stats (total events, events/second rate) sent every 5s |
Eval event
{ "type": "eval", "ts": "2026-02-16T14:30:00.123Z", "flag": "dark_mode", "reason": "targeting", "ms": 2.3, "actor": "ff_prod_a1b2...", "value": true, "ctx": "user=u_42 plan=premium env=production"}| Field | Description |
|---|---|
ts | ISO 8601 timestamp |
flag | Flag key that was evaluated |
reason | Evaluation reason |
ms | Evaluation duration in milliseconds |
actor | Truncated token prefix of the caller |
value | Evaluated flag value |
ctx | Compact summary of the evaluation context (max 100 chars) |
Backfill
Sent immediately after a start message:
{ "type": "backfill", "events": [ { "ts": "...", "flag": "dark_mode", "reason": "targeting", "ms": 1.2, "actor": "...", "value": true, "ctx": "..." } ]}Stats
Broadcast every 5 seconds to all connected viewers:
{ "type": "stats", "total": 15234, "rate": 42.5}Architecture
The live tail uses a dedicated Durable Object (TailRoomDO) separate from the flag sync DO. Evaluation routes emit events via waitUntil() with zero overhead when no viewers are connected. The DO maintains a 500-event ring buffer and broadcasts to all connected admin viewers with per-connection filtering.
Evaluation request → Worker → waitUntil(emit) → TailRoomDO → broadcast → Admin UIConnection lifecycle
Both WebSocket endpoints follow the same connection state machine:
| State | Description |
|---|---|
| disconnected | No active connection. Initial state or after all retries exhausted. |
| connecting | WebSocket handshake in progress. |
| connected | Handshake complete, messages flowing. |
| reconnecting | Connection lost, attempting to re-establish. |
State transitions
disconnected → connecting → connected ↓ reconnecting → connecting → connected ↓ disconnected (max retries)The SDK and admin dashboard manage these transitions automatically. On a successful connection, the client sends a subscribe (flag sync) or start (live tail) message to begin receiving data.
When the connection drops, the client moves to reconnecting and begins the backoff sequence. On reconnection, the client re-subscribes with the same parameters, and the server sends a fresh snapshot or backfill to ensure the client has current state.
Reconnection
Reconnection uses exponential backoff with a cap:
| Attempt | Delay |
|---|---|
| 1 | 1 s |
| 2 | 2 s |
| 3 | 4 s |
| 4 | 8 s |
| 5+ | 16 s (cap) |
The backoff counter resets to zero after a successful connection that lasts more than 30 seconds. If the server returns a 4xx status during the WebSocket handshake (e.g. 401 Unauthorized), reconnection stops immediately since retrying would not help.
Server-side architecture
Both streaming endpoints use the Cloudflare Hibernatable WebSocket API. This means idle connections consume zero CPU — the Durable Object hibernates when no messages are in flight and wakes only when a message arrives or a broadcast is triggered.
Per-connection state (subscribed environment, active filters, pause state) is stored using serializeAttachment / deserializeAttachment on each WebSocket, so it survives hibernation without keeping the DO in memory.
| Durable Object | Endpoint | Purpose |
|---|---|---|
FlagSyncDO | /stream/flags | Maintains evaluated flag state, computes and pushes diffs on change |
TailRoomDO | /stream/tail | Broadcasts evaluation events, manages 500-event ring buffer |
When no viewers are connected to TailRoomDO, the waitUntil(emit) call in the evaluation path short-circuits via a connection count check, adding zero latency to normal flag evaluations.