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'
} 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)' }]
} 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?