Errors
Server - LiveError
Throw LiveError from any live() function:
import { live, LiveError } from 'svelte-realtime/server';
export const deleteItem = live(async (ctx, id) => {
if (!ctx.user) throw new LiveError('UNAUTHORIZED', 'Login required');
const item = await db.items.get(id);
if (!item) throw new LiveError('NOT_FOUND', 'Item not found');
if (item.ownerId !== ctx.user.id) throw new LiveError('FORBIDDEN', 'Not yours');
await db.items.delete(id);
}); Client - RpcError
On the client, server errors arrive as RpcError:
import { deleteItem } from '$live/items';
try {
await deleteItem(id);
} catch (err) {
err.code; // 'UNAUTHORIZED', 'NOT_FOUND', 'FORBIDDEN', etc.
err.message; // 'Login required'
} Error code catalog
Codes the framework itself throws. Application-thrown LiveError(code, ...) can use any code string - these are the well-known ones the framework documents and client code commonly branches on.
| Code | Source | Cause |
|---|---|---|
UNAUTHENTICATED | framework / app | Caller has no valid session. Typical from a live.gate(async (ctx) => !!ctx.user, ...) predicate. |
UNAUTHORIZED | app | Caller is authenticated but lacks permission. Idiomatic for app-thrown LiveError('UNAUTHORIZED', ...). |
FORBIDDEN | framework | live.gate(...) predicate returned falsy, or live.access.* denied. Async predicates now correctly deny since 0.5.0. |
NOT_FOUND | app / live.push | Resource missing. live.push uses this when the target user has no live connection and no remoteRegistry resolved. |
VALIDATION | framework | live.validated() rejected the input. Carries an issues array. |
INVALID_REQUEST | framework | Malformed RPC envelope. Also fires when idempotencyKey exceeds 256 chars. |
INVALID_TOPIC | framework | Topic name starts with reserved __ prefix, exceeds the printable-ASCII char class, or violates the wire-topic rules. ctx.publish('__*') throws this since 0.5.0. |
INVALID_ARG | framework | Argument shape rejected by a primitive’s input validation (e.g. ctx.skip(key, ms) with non-string key or non-positive ms). |
INVALID_USER_ID | framework | live.push / live.notify / ctx.signal called with a non-string or empty user id. |
RATE_LIMITED | framework | live.rateLimit / live.rateLimits / createRateLimit extension threw. Carries resetMs (when available). |
OVERLOADED | app | Convention for ctx.shed(...) admission-rejected handlers. The framework recommends this code in the admission docs. |
SERVICE_UNAVAILABLE | framework | Circuit breaker open (live.breaker) and no fallback configured. |
IDEMPOTENCY_RESULT_TOO_LARGE | framework | live.idempotent cached result exceeded the configured per-entry size cap. |
TIMEOUT | framework | RPC, live.push, ctx.signal, or platform.request exceeded its deadline. |
CONNECTION_CLOSED | framework | WebSocket closed terminally before the RPC resolved. |
LOCK_TIMEOUT | framework | live.lock / createDistributedLock waited past maxWaitMs. Carries .key, .maxWaitMs. |
LOCK_CLEARED | framework | A pending waiter was cleared by lock.clear(). |
LOCK_QUEUE_FULL | framework | Per-key waiter queue exceeded maxWaitersPerKey (default 100). |
WS_CLOSED | extensions | presence.join() / cursor.attach() from extensions threw because the WS closed during an internal async gap. Throw class WsClosedError, re-exported from both modules. |
QUEUE_FULL | client (offline queue) | Offline queue exceeded maxQueue; oldest entry dropped. |
STALE | client (offline queue) | Queued call exceeded maxAge at replay time. |
The well-known LiveError is exported from svelte-realtime/server; RpcError from svelte-realtime/client. WsClosedError is re-exported from both svelte-adapter-uws-extensions/redis/presence and svelte-adapter-uws-extensions/redis/cursor.
Catch by code, not by message text:
- if (err.message.includes('timed out')) retry();
+ if (err.code === 'TIMEOUT') retry(); Stream error and status stores
Stream stores never replace your data with an error object. The data store is always your data type or undefined while loading. Errors and connection status live on separate reactive stores so a network failure can never crash your UI:
| Property | Type | Description |
|---|---|---|
$store | T \| undefined | Your data. Never replaced by an error object. On failure, the last loaded value is preserved. |
store.error | Readable<RpcError \| null> | Current error, or null when healthy |
store.status | Readable<'loading' \| 'connected' \| 'reconnecting' \| 'error'> | Connection status |
Handle loading in your template, and subscribe to .error / .status to surface errors and reconnection state:
<script>
import { messages } from '$live/chat';
const err = messages.error;
const status = messages.status;
</script>
{#if $err}
<p class="banner">Error: {$err.message} ({$err.code})</p>
{/if}
{#if $status === 'reconnecting'}
<p class="banner">Reconnecting...</p>
{/if}
{#if $messages === undefined}
<p>Loading...</p>
{:else}
{#each $messages as msg (msg.id)}
<p>{msg.text}</p>
{/each}
{/if} Defensive patterns like ($messages ?? []).filter(...) work correctly because $messages is always your data type or undefined.
Validation errors
live.validated() throws RpcError with code: 'VALIDATION' and an issues array:
try {
await addTodo({ text: '' });
} catch (err) {
err.code; // 'VALIDATION'
err.issues; // [{ path: ['text'], message: 'String must contain at least 1 character(s)' }]
} live.push reject codes
live.push(target, ...) rejects with structured LiveError codes - no need to substring-sniff err.message:
| Code | Cause |
|---|---|
VALIDATION | Argument validation rejected (Standard Schema). |
NOT_FOUND | Target user has no live connection on this instance and no remoteRegistry resolved. |
TIMEOUT | Adapter primitive’s deadline expired. Default timeoutMs: 5000. |
CONNECTION_CLOSED | Connection terminated mid-call. |
| caller-defined | The handler threw a LiveError(code, ...) with any other code. |
Message text is preserved verbatim on .message; the original underlying error rides on .cause.
- if (err.message.includes('timed out')) retry();
+ if (err.code === 'TIMEOUT') retry(); Production assertions
Critical framework invariants are checked via lightweight assert(cond, category, context) hooks in production. Violations log structured [svelte-realtime/assert] records and increment a Prometheus counter; in test mode they throw.
import { live } from 'svelte-realtime/server';
import { createMetrics } from 'svelte-adapter-uws-extensions/prometheus';
const metrics = createMetrics();
live.metrics(metrics);
// metric: svelte_realtime_assertion_violations_total{category} Categories cover envelope shape, subscription bookkeeping, push-registry CAD, lock-waiter shape, optimistic-queue pairing, drain-precondition, and settle-entry shape. A non-zero counter in production is a framework bug; report against the changelog entry the category maps to. getAssertionCounters() returns the in-process snapshot for tests.
Microtask RPC dedup warn-once
In dev mode, calling the same RPC path twice in the same microtask logs a one-line warning the first time it happens with a pointer to .fresh(...) for opt-out. Stripped under NODE_ENV=production.
Terminal close codes
When the WebSocket closes terminally (codes 1008, 4401, 4403, exhausted retries):
- All pending RPCs reject with
RpcError('CONNECTION_CLOSED') - All stream stores set
.error(the data value is preserved) - Offline queue is drained with errors
Error reporting hooks
onError
Both handleRpc and createMessage accept an onError callback for non-LiveError exceptions. LiveError throws are expected errors sent to the client; everything else is an unexpected failure that should be reported.
export const message = createMessage({
onError(path, error, ctx) {
sentry.captureException(error, {
tags: { rpc: path },
user: { id: ctx.user?.id }
});
}
}); The callback receives three arguments:
| Argument | Description |
|---|---|
path | The RPC path that failed (e.g. chat/sendMessage) |
error | The raw error object |
ctx | The request context (includes user, ws, etc.) |
Standalone onError
For errors in cron jobs, effects, and derived streams, use the standalone onError function:
import { onError } from 'svelte-realtime/server';
onError((path, error) => {
sentry.captureException(error, { tags: { live: path } });
});
onCronErrorstill works but is deprecated - useonErrorinstead.
Reusable error boundary component
For Svelte 5, you can build a reusable boundary that handles loading and error states:
<!-- src/lib/StreamView.svelte -->
<script>
/** @type {{ store: any, children: import('svelte').Snippet, loading?: import('svelte').Snippet, error?: import('svelte').Snippet<[any]> }} */
let { store, children, loading, error } = $props();
let value = $derived($store);
const err = store.error;
</script>
{#if value === undefined}
{#if loading}
{@render loading()}
{:else}
<p>Loading...</p>
{/if}
{:else if $err}
{#if error}
{@render error($err)}
{:else}
<p>Error: {$err.message}</p>
{/if}
{:else}
{@render children()}
{/if} Use it to wrap any stream:
<script>
import StreamView from '$lib/StreamView.svelte';
import { messages, sendMessage } from '$live/chat';
</script>
<StreamView store={messages}>
{#each $messages as msg (msg.id)}
<p>{msg.text}</p>
{/each}
{#snippet loading()}
<div class="skeleton-loader">Loading messages...</div>
{/snippet}
{#snippet error(err)}
<div class="error-banner">
<p>Could not load messages: {err.message}</p>
<button onclick={() => location.reload()}>Retry</button>
</div>
{/snippet}
</StreamView> With default slots, the minimal version is just:
<StreamView store={messages}>
{#each $messages as msg (msg.id)}
<p>{msg.text}</p>
{/each}
</StreamView> This removes the {#if}/{:else} boilerplate from every page that uses a stream.
Cloudflare-Tunnel “open then close 1006” detector
If the client sees two consecutive WebSocket open then close cycles inside one second with no traffic, it logs a one-shot console.warn with the diagnosis. That fingerprint usually means an edge proxy (most often Cloudflare Tunnel) is dropping Set-Cookie on the 101 Switching Protocols response. See Cloudflare-Tunnel cookie fix for the diagnosis, root cause, and fix.
Was this page helpful?