useConnectionStatus

One honest signal for whether the server is reachable — derived, never polled.

Connection health from the signals the client genuinely has, so apps stop hand-rolling a degraded-connection state machine in an auth provider.

import { useConnectionStatus } from '@voltro/client'

const connection = useConnectionStatus('app')
{(connection.status === 'degraded' || connection.status === 'offline') && (
  <OfflineBanner status={connection.status} />
)}

status is 'connected' | 'recovering' | 'degraded' | 'offline'. recovering is the configurable grace window after an observed transport loss (1.5 seconds by default): raw diagnostics already know the socket is down, but the UI can stay quiet while the client reconnects. If every affected subscription produces its own snapshot or resumed delta inside that window, the state returns directly to connected; there is no failure flash. If the window expires, it changes once to degraded. offline is browser-reported (navigator.onLine plus its online / offline events) and wins over both.

For a unary call, a later rpc success or declared handler reply proves the server answered and clears a generic transport failure. For a failed subscription, only a later snapshot or resumed delta from that same tag clears its pending state. An unrelated heartbeat cannot label a screen healthy while one of its streams is still terminal. Local client and unknown failures leave connection state unchanged. The browser coming back online does not prove that the rpc socket recovered; raw state stays disconnected until the protocol is ready. An explicit reportSuccess() may clear a generic transport failure, but does not override pending subscription recovery.

Use this hook to render aggregate connection state. To classify the failure that is firing right now, branch on event.kind inside useOnRpcError. React state updates after the event and cannot classify that event retroactively.

Also returned: rawTransport (immediate, never grace-filtered), online, failureCount, lastFailureAt, failedSubscriptions, pendingMutationReplays, and mutationReplay. There is deliberately no polling ping — connected means "no observed transport failure is still unresolved", not "just verified".

Configure the bounded policy in the web app. The defaults are shown here:

// apps/web/app.config.ts
export default {
  web: {
    api: {
      recovery: {
        graceMs: 1_500,
        retry: { initialMs: 0, thenMs: 500, factor: 1.5, maxMs: 5_000 },
        maxPendingMutations: 32,
        replayWindowMs: 10_000,
        // Usually derived beside the WS path. Set only when your edge routes
        // WebSocket and HTTP through unrelated paths:
        // capabilitiesUrl: '/api/_voltro/runtime-capabilities',
      },
    },
  },
}

Values are clamped (graceMs 0–30 s, pending mutations 0–256, replay window 250 ms–60 s), so a broken config cannot create an unbounded client queue.

The first socket retry has no deliberate delay. If it fails, the waits are 500, 750, 1125, 1687.5, 2531.25, 3796.875, then 5000 ms repeatedly. Each wait starts after the preceding attempt fails; DNS, TLS and upgrade time come on top. A protocol-ready connection resets the sequence for the next outage. graceMs controls presentation only; it does not postpone connection attempts. Retry settings may be partial: omitted or non-finite fields use their defaults. maxMs is clamped to 50–60000 ms, initialMs to 0–maxMs, thenMs to 50–maxMs, and factor to 1–10. Delay settings are truncated to integer ms; later exponential steps may be fractional. The positive thenMs minimum prevents an endless zero-delay loop.

Change this in the web app's app.config.ts, then rebuild and redeploy the web app. Programmatic mount() accepts the same recovery options, including per-API overrides; React Native uses MobileApiBinding.recovery. Normal socket retries keep the runtime and its subscription bases/revisions. refreshAuth() explicitly rebuilds with fresh auth headers for the same subject; reconnect() rebuilds for a subject change. Neither action waits for backoff. Header resolvers run per runtime build; a socket cut no longer causes their re-evaluation through a competing rebuild. The web host still handles supported auth errors with bounded wireAuthRefresh. Custom hosts can call SupervisorHandle.refreshAuth() after token rotation.

For a measurement, open browser DevTools on the affected page:

voltroDiagnostics.enable()
voltroDiagnostics.cut('app', 6000)
// Wait for recovery, then export the evidence:
voltroDiagnostics.download('app')

The export contains socket.retry-scheduled with attempt, delayMs, generation, and owner (socket or runtime-build). Compare it with socket.created, socket.raw-open, protocol.ready, and each subscription's first delivery. This separates backoff, connection setup and data recovery. The 400-event ring can drop older events; keep the native WebSocket timing capture too when diagnosing a busy page.

Custom transport adapters: startApiSupervisor retries rejected buildClient promises. Once a runtime exists, its transport must retry socket failures itself (buildApiRuntime does this). onSocketIssue reports health and drives the wedge backstop; it no longer schedules another runtime build. retryDelayMs overrides only failed construction retries. Use recovery.retry for sockets. Disposal must stop the adapter's retry loop.