This document explains the real-time update system powered by Kubernetes Watch API.
The Watch API provides instant updates when resources change, replacing the previous polling approach. This was implemented in ADR-003.
| Aspect | Polling (Before) | Watch API (After) |
|---|---|---|
| Update latency | 5-10 seconds | Instant |
| Requests/min | 6-12 per resource | 1 (persistent connection) |
| Server load | High (repeated requests) | Low (event stream) |
| Connection type | HTTP request/response | Server-Sent Events (SSE) |
The watch system uses a server-side multiplexer to avoid HTTP/1.1 connection starvation. Instead of each resource opening its own SSE connection (which would exhaust the browser's 6-connection-per-origin limit), all watch subscriptions are multiplexed through a single SSE stream per browser tab.
┌─────────────────────────────────────────────────────────────────┐
│ Browser Tab │
│ │
│ useDomainsWatch()─┐ │
│ useDnsZonesWatch()─┼─▶ WatchManager ──1 SSE──┐ │
│ useSecretsWatch()──┘ (client-side) │ │
│ │ │
└────────────────────────────────────────────────┼────────────────┘
│
POST /subscribe
POST /unsubscribe
│
┌────────────────────────────────────────────────┼────────────────┐
│ Hono Server │ │
│ ▼ │
│ WatchHub │
│ (server-side) │
│ │ │ │
│ ┌───────────────────┘ └──────┐ │
│ ▼ ▼ │
│ fetch (upstream) fetch (upstream) │
│ K8s Watch: domains K8s Watch: dnszones │
│ │
└─────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌───────────────────────────────────────────────┐
│ Control Plane (Kubernetes) │
│ Watch API streams │
└───────────────────────────────────────────────┘
| Component | Connections | Scope |
|---|---|---|
| Browser → Server | 1 SSE | Per tab |
| Server → K8s | 1 per resource type | Shared across tabs |
- Browser opens
GET /api/watch/stream?cid=<uuid>(single SSE connection) - Browser sends
POST /api/watch/subscribewith{ clientId, resourceType, projectId, namespace, ... } - Server starts an upstream K8s Watch fetch if one doesn't exist for this channel
- K8s streams NDJSON events → Server parses and fans out via SSE to all subscribed clients
- Browser dispatches events to
useResourceWatchcallbacks → React Query cache updates - Browser sends
POST /api/watch/unsubscribewhen a component unmounts - Server starts a 10-second grace period; if no one re-subscribes, upstream is closed
Kubernetes Watch sends these event types:
| Type | Meaning | Typical Action |
|---|---|---|
ADDED |
Resource created | Add to list/cache |
MODIFIED |
Resource updated | Update in list/cache |
DELETED |
Resource removed | Remove from list/cache |
BOOKMARK |
Version marker | Track resourceVersion |
ERROR |
Watch error | Log / reconnect |
interface WatchEvent<T> {
type: 'ADDED' | 'MODIFIED' | 'DELETED' | 'BOOKMARK' | 'ERROR';
object: T; // The full resource object
}import { useDnsZonesWatch } from '@/resources/dns-zones';
function DnsZonesPage() {
const { data: zones } = useDnsZones(projectId);
// Enable real-time updates
useDnsZonesWatch(projectId);
return <ZoneList zones={zones} />;
}// Disable watch conditionally
useDnsZonesWatch(projectId, { enabled: isListView });
// Watch a single resource
useDnsZoneWatch(projectId, zoneName);For task queue processors that need to wait for a resource to become ready:
import { waitForDnsZoneReady } from '@/resources/dns-zones';
const processor = async () => {
const response = await createDnsZone({ projectId, body: zoneSpec });
// Subscribes to watch, resolves when status is Ready
const zone = await waitForDnsZoneReady(projectId, response.data.metadata.name);
return zone;
};Each resource defines watch hooks in its *.watch.ts file:
// resources/dns-zones/dns-zone.watch.ts
import { useResourceWatch } from '@/modules/watch';
export function useDnsZonesWatch(projectId: string, options?: { enabled?: boolean }) {
return useResourceWatch<DnsZone>({
resourceType: 'apis/dns.networking.miloapis.com/v1alpha1/dnszones',
projectId,
namespace: 'default',
queryKey: dnsZoneKeys.list(projectId),
transform: (item) => toDnsZone(item),
enabled: options?.enabled ?? true,
// In-place update for MODIFIED events (avoids full list refetch)
getItemKey: (zone) => zone.name,
// Throttle for ADDED/DELETED events that use invalidation
throttleMs: 1000,
debounceMs: 300,
skipInitialSync: true,
});
}| Option | Description | Default |
|---|---|---|
throttleMs |
Min interval between list refetches | 1000 |
debounceMs |
Batch window for rapid events | 300 |
skipInitialSync |
Ignore ADDED events in first 2s (cache already hydrated by SSR) | true |
getItemKey |
Extract unique ID for in-place MODIFIED updates | - |
updateListCache |
Custom cache updater for non-array data structures | - |
app/modules/watch/ # Client-side watch infrastructure
├── watch.manager.ts # Multiplexed SSE client (singleton)
├── use-resource-watch.ts # React hook for watch subscriptions
├── watch-wait.helper.ts # Promise wrapper for async K8s ops
├── watch.context.tsx # React context provider
├── watch.parser.ts # NDJSON event parser
├── watch.types.ts # Shared type definitions
└── index.ts # Barrel exports
app/server/watch/ # Server-side watch multiplexer
├── watch-hub.ts # WatchHub engine (singleton)
├── watch-hub.types.ts # Server-side type definitions
└── index.ts # Barrel exports
app/server/routes/watch.ts # HTTP endpoints for the watch protocol
// Show current connection state, active channels, and subscriber counts
window.__watchStatus();curl http://localhost:3000/api/watch/stats | jqReturns:
{
"clients": 1,
"upstreams": 2,
"subscriptions": {
"apis/networking.datumapis.com/v1alpha/domains::proj-abc:default:::": 1,
"apis/dns.networking.miloapis.com/v1alpha1/dnszones::proj-abc:default:::": 1
}
}- Open DevTools → Network tab
- Filter by "Fetch/XHR" and look for
/api/watch/stream - Click the stream request → EventStream tab shows live events
- Look for
subscribe/unsubscribePOST requests
| Issue | Cause | Solution |
|---|---|---|
| No events received | Upstream URL wrong | Check buildUpstreamUrl in watch-hub.ts |
| 401 on upstream | Token expired | Token refreshed on each subscribe; re-login if needed |
| Events stop after 30s | K8s watch timeout (expected) | Server auto-reconnects with latest resourceVersion |
| Subscription leaks | React Strict Mode callback | WatchManager.subscribe cleans stale callbacks |
| Channel not unsubscribed | Multiple subscribers remain | Check __watchStatus() for subscriber counts |
| 410 Gone in server logs | resourceVersion expired | Server silently reconnects (no client notification) |
These resources have real-time updates:
| Resource | List Watch Hook | Detail Watch Hook |
|---|---|---|
| DNS Zones | useDnsZonesWatch() |
useDnsZoneWatch() |
| DNS Records | useDnsRecordSetsWatch() |
useDnsRecordSetWatch() |
| Domains | useDomainsWatch() |
useDomainWatch() |
| Secrets | useSecretsWatch() |
useSecretWatch() |
| HTTP Proxies | useHttpProxiesWatch() |
useHttpProxyWatch() |
| Export Policies | useExportPoliciesWatch() |
- |
- Create
resources/{resource}/{resource}.watch.ts - Define
use{Resource}Watch()usinguseResourceWatchfrom@/modules/watch - Optionally define
waitFor{Resource}Ready()usingwaitForWatchfor task queue integration - Export from the resource's
index.tsbarrel - Call the hook in the list/detail page components
See Adding a New Resource for full implementation steps.