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:

PropertyTypeDescription
$storeT \| undefinedYour data. Never replaced by an error object. On failure, the last loaded value is preserved.
store.errorReadable<RpcError \| null>Current error, or null when healthy
store.statusReadable<'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:

ArgumentDescription
pathThe RPC path that failed (e.g. chat/sendMessage)
errorThe raw error object
ctxThe 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 } });
});

onCronError still works but is deprecated - use onError instead.


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?