Client APIs

Virtual imports

Every file in src/live/ generates a $live/ import. The Vite plugin reads your server code and generates lightweight client stubs.

// src/live/chat.js exports sendMessage (live) and messages (stream)
import { sendMessage, messages } from '$live/chat';
  • live() exports become async functions
  • live.stream() exports become Svelte stores
  • live.validated() exports become async functions (validation runs server-side)

Store subscription

Stream stores are standard Svelte stores. Use the $ prefix:

<script>
  import { messages } from '$live/chat';
</script>

{#each $messages as msg (msg.id)}
  <p>{msg.text}</p>
{/each}

store.rune() and store.map(fn)

Two helpers on every stream store that simplify the common projection patterns:

<script>
  import { todos } from '$live/todos';

  // Svelte 5 rune view (via fromStore under the hood):
  const $todos = todos.rune();

  // Per-item projection - returns another store of the same shape:
  const titles = todos.map(t => t.title);
</script>

{#each $titles as title}<li>{title}</li>{/each}

store.rune() requires Svelte 5 (uses fromStore). Svelte 4 apps use the $store auto-subscribe syntax instead; calling rune() on Svelte 4 throws a descriptive error.

store.map(fn) is the idiomatic alternative to $derived.by(() => ($stream ?? []).map(...)) and avoids the $derived(() => ...) footgun where storing a function reference instead of its return value silently breaks rendering. Semantics match the documented ($stream ?? []).map(fn) pattern: a null / undefined source emits []; an array source emits source.map(fn). A non-array source (a set-merge stream, a latest-merge stream, the paginated wrapper) emits [] after a one-time dev-mode console.warn pointing at the merge-strategy docs - the warn surfaces the type mismatch instead of silently rendering an empty list. Subscriptions are lazy: the source is only subscribed while at least one mapped consumer is active. Chains compose; .map(...).map(...) preserves the shape.

Pagination

Return { data, hasMore, cursor } from your stream init to enable cursor pagination:

// Server
export const posts = live.stream('posts', async (ctx) => {
  const limit = 20;
  const rows = await db.posts.list({ limit: limit + 1, after: ctx.cursor });
  const hasMore = rows.length > limit;
  const data = hasMore ? rows.slice(0, limit) : rows;
  const cursor = data.at(-1)?.id ?? null;
  return { data, hasMore, cursor };
}, { merge: 'crud', key: 'id' });
<!-- Client -->
<script>
  import { posts } from '$live/feed';
</script>

{#each $posts as post (post.id)}
  <p>{post.title}</p>
{/each}

{#if posts.hasMore}
  <button onclick={() => posts.loadMore()}>Load more</button>
{/if}

Optimistic updates

Apply changes instantly, roll back on failure:

<script>
  import { todos, addTodo } from '$live/todos';

  async function add(text) {
    const tempId = 'temp-' + Date.now();
    const rollback = todos.optimistic('created', { id: tempId, text });
    try {
      await addTodo(text);
    } catch {
      rollback();
    }
  }
</script>

Auto-rollback with store.mutate()

store.mutate(asyncOp, change) wraps apply-await-rollback. The change is applied synchronously, the RPC awaits, and on rejection the change rolls back and the error re-throws. The change accepts the same two shapes as store.optimistic:

<script>
  import { todos, addTodo, removeTodo } from '$live/todos';

  // Event-shape: the server's confirming event reconciles by key
  async function add(text) {
    await todos.mutate(
      () => addTodo(text),
      { event: 'created', data: { id: 'temp-' + Date.now(), text } }
    );
  }

  // Free-form mutator: arbitrary local change, no merge-strategy assumptions
  async function remove(id) {
    await todos.mutate(
      () => removeTodo(id),
      (current) => current.filter((t) => t.id !== id)
    );
  }
</script>

Concurrent mutate calls roll back independently - the displayed value is recomputed by replaying every still-in-flight entry against the un-overlaid server state after each server event and each settle, so two failing mutates do not leave phantom traces of either.

On array-merge streams (crud / presence / cursor / latest), the free-form mutator sees current as [] instead of undefined if the optimistic change fires before the stream’s loader has resolved (e.g. a user clicking faster than a Redis-round-trip loader). Idiomatic spreads like (current) => [...current, item] therefore do not throw on a fast-clicking user; the in-flight entry replays against the real server state once it arrives. set-merge streams keep the original current === undefined shape, since set change functions are already expected to handle the un-loaded case.

Undo and redo

<script>
  import { todos } from '$live/todos';
  todos.enableHistory(100);
</script>

<button onclick={() => todos.undo()} disabled={!todos.canUndo}>Undo</button>
<button onclick={() => todos.redo()} disabled={!todos.canRedo}>Redo</button>

SSR hydration

Load data server-side, then hydrate the client store:

// src/routes/chat/+page.server.js
export async function load({ platform }) {
  const { messages } = await import('$live/chat');
  return { messages: await messages.load(platform) };
}
<script>
  import { messages } from '$live/chat';
  let { data } = $props();
  const msgs = messages.hydrate(data.messages);
</script>

The hydrated store still subscribes for live updates on first render. It keeps the SSR data visible instead of showing undefined during the initial fetch. Guards still run during .load() calls. Pass { user } as the second argument if your guard or init function needs user data.

For dynamic streams (those with a topic function), call the stream first to get the per-args store, then hydrate:

// src/routes/team/[id]/+page.server.js
export async function load({ platform, locals, params }) {
  const { invitations } = await import('$live/invitation');
  const data = await invitations.load(platform, { args: [params.id], user: locals.user });
  return { invitations: data };
}
<!-- src/routes/team/[id]/+page.svelte -->
<script>
  import { invitations } from '$live/invitation';
  import { page } from '$app/state';
  let { data } = $props();

  const invites = invitations(page.params.id).hydrate(data.invitations);
</script>

{#each $invites as invite (invite.id)}
  <p>{invite.email}</p>
{/each}

Connection state

The connection exposes four reactive stores covering the full state machine:

StoreTypeDescription
statusReadable<'connecting' \| 'open' \| 'suspended' \| 'disconnected' \| 'failed'>Five-state connection machine. 'suspended' is open-but-tab-backgrounded. 'failed' is terminal (auth denied, max retries exhausted, or close() called).
denialsReadable<{ topic, reason, ref } \| null>Latest subscribe denial. Reasons: 'UNAUTHENTICATED' \| 'FORBIDDEN' \| 'INVALID_TOPIC' \| 'RATE_LIMITED' or a string from your subscribe hook.
failureReadable<{ kind, code?, status?, reason } \| null>Discriminated union by kind: 'ws-close' carries code, 'auth-preflight' carries status. reason label is one of 'TERMINAL' \| 'EXHAUSTED' \| 'THROTTLE' \| 'RETRY' \| 'AUTH'.
eventsevent emitter'connect' \| 'disconnect' \| 'reconnect-attempt'.
<script>
  import { status, denials, failure } from 'svelte-realtime/client';
</script>

{#if $status === 'failed'}
  <p>Connection failed: {$failure?.reason}</p>
{/if}

{#if $denials}
  <p>Cannot subscribe to {$denials.topic}: {$denials.reason}</p>
{/if}

ready() resolves on 'open' OR 'suspended'. The reconnect curve is 2.2^attempt with a 5-minute cap; pass { maxReconnectInterval: 30000 } to restore the previous 30-second cap.

import { classifyCloseCode } from 'svelte-realtime/client';

classifyCloseCode(1006); // 'RETRY'
classifyCloseCode(4401); // 'TERMINAL'
classifyCloseCode(4429); // 'THROTTLE'

conn.bufferedAmount getter on the connection mirrors the browser’s WebSocket.bufferedAmount (0 pre-connect / post-close). Used by uploads and any code that needs to back off when the send queue is saturated.

Connection hooks

<script>
  import { configure } from 'svelte-realtime/client';

  configure({
    onConnect() { /* reconnected */ },
    onDisconnect() { /* connection lost */ },
    beforeReconnect() { /* before each reconnect attempt (can be async) */ }
  });
</script>
OptionDescription
urlFull WebSocket URL for cross-origin or native app usage (e.g. 'wss://api.example.com/ws')
authtrue (or a custom path) to enable an HTTP preflight before each WebSocket upgrade so cookies set by the server’s authenticate hook ride a normal HTTP response. Required behind Cloudflare Tunnel and other proxies that drop Set-Cookie on 101 responses. Requires svelte-adapter-uws >= 0.4.12. See Cloudflare-Tunnel cookie fix.
onConnect()Called when the WebSocket connection opens after a reconnect
onDisconnect()Called when the WebSocket connection closes
beforeReconnect()Called before each reconnection attempt (can be async)
timeoutDefault RPC timeout in ms (default 30_000). Per-call .with({ timeout }) overrides.
resumeGraceMsStream resume-grace window in ms (default 60_000, added 0.5.5). See Resume grace below.
volatileBackpressureBytesBackpressure cap for .fireAndForget() (default 4 MB, added 0.5.8). See Volatile backpressure cap.
upload{ frameSize?: number } to override the per-chunk frame size for live.upload. Default auto-derived from platform.maxPayloadLength.
offline{ queue, maxQueue, maxAge, beforeReplay, onReplayError }. See Offline queue below.

Cross-origin and native app usage

When using svelte-realtime from a client that runs on a different origin (Svelte Native, React Native, or any standalone app), pass the url option to point at your SvelteKit backend:

import { configure } from 'svelte-realtime/client';

configure({
  url: 'wss://my-sveltekit-app.com/ws'
});

When url is set, the default same-origin WebSocket URL is bypassed entirely. All RPC calls, streams, and pub/sub work the same way. Requires svelte-adapter-uws 0.4.8+.

Combine stores

<script>
  import { combine } from 'svelte-realtime/client';
  import { orders, inventory } from '$live/dashboard';

  const dashboard = combine(orders, inventory, (o, i) => ({
    pending: o?.filter(x => x.status === 'pending').length ?? 0,
    lowStock: i?.filter(x => x.qty < 10) ?? []
  }));
</script>

Offline queue

import { configure } from 'svelte-realtime/client';

configure({
  offline: {
    queue: true,
    maxQueue: 100,
    maxAge: 60000,
    beforeReplay(call) {
      // Return false to drop stale mutations
      return Date.now() - call.queuedAt < 60000;
    },
    onReplayError(call, error) {
      console.warn('Replay failed:', call.path, error);
    }
  }
});

When offline queuing is enabled, RPC calls made while disconnected return promises that resolve when the call is replayed after reconnection. If the queue overflows, the oldest entry is dropped and its promise rejects with QUEUE_FULL. If maxAge is set, queued calls older than that threshold are rejected with STALE at replay time.

Note: volatile RPCs (.fireAndForget(...)) do NOT enter the offline queue - they are silently dropped while disconnected. The offline queue is for awaited mutations; volatile calls are intentionally lossy under disconnect.

Resume grace

configure({
  resumeGraceMs: 60_000  // default
});

When the last subscriber of a stream unsubs, the stream releases its WebSocket subscription immediately (giving the server back its slot) but keeps the in-memory data model - currentValue, the last seen seq / version, the pagination cursor, history - for resumeGraceMs (default 60 seconds, added in 0.5.5). A new subscribe() inside that window re-attaches its listeners and sends the retained cursor on the resume envelope so the server fills the gap from its bounded replay buffer instead of cold-starting.

configure({ resumeGraceMs: 0 });        // every unsub is a full reset (pre-0.5.5 behavior)
configure({ resumeGraceMs: 5_000 });    // 5s grace covers brief toggles
configure({ resumeGraceMs: 300_000 });  // 5min grace for navigation-heavy apps

This is the default for two reasons:

  1. Pause/resume UIs work for free. A {#if active} <SubscribedComponent /> {/if} toggle, or an $effect whose subscribe arm flips on user action, can pause and resume the subscription without re-loading from scratch. The events that arrived during the pause stream in via the replay buffer.
  2. Browser back/forward feels instant. Navigating away and back within the grace window restores the previous data immediately, and any events the user missed are gap-filled by the server.

If the grace expires without a new subscriber, the data model resets and the next subscribe is a true cold start. The grace only affects local data retention - the server’s replay buffer and delta.fromSeq window are independent.

Volatile backpressure cap

configure({
  volatileBackpressureBytes: 4 * 1024 * 1024  // default 4 MB
});

For .fireAndForget() calls, the client reads WS.bufferedAmount before send; if it exceeds this threshold the frame is dropped silently and __devtools.volatileDropped ticks. Default 4 MB is sized for 120 Hz cursor + drag traffic (~24 KB/sec per client) - healthy demos never trip it, but a genuinely stuck connection drops volatile frames before the browser send buffer can OOM. Dev-mode emits a one-shot console.warn on first drop per session.

Delta sync and replay

Delta sync

Enable delta sync for efficient reconnection on streams with large datasets. Instead of refetching all data, the server sends only what changed since the client’s last known version.

export const inventory = live.stream('inventory', async (ctx) => {
  return db.inventory.all();
}, {
  merge: 'crud',
  key: 'sku',
  delta: {
    version: () => db.inventory.lastModified(),
    diff: async (sinceVersion) => {
      const changes = await db.inventory.changedSince(sinceVersion);
      return changes; // null to force full refetch
    }
  }
});

How it works:

  • On first connect, the client gets the full dataset plus a version value
  • On reconnect, the client sends its last known version
  • If versions match: server responds with { unchanged: true } (nearly zero bytes)
  • If versions differ: server calls diff(sinceVersion) and sends only the changes
  • If diff returns null: falls back to full refetch

Replay

Enable seq-based replay for gap-free stream reconnection. When a client reconnects, it sends its last known sequence number. If the server has the missed events buffered, it sends only those instead of a full refetch.

export const feed = live.stream('feed', async (ctx) => {
  return db.feed.latest(50);
}, { merge: 'latest', max: 50, replay: true });

Replay requires the replay extension from svelte-adapter-uws-extensions. When replay is not available or the gap is too large, the client falls back to a full refetch automatically.

With adapter 0.4.0+, the replay end marker sends { reqId } (replay complete) or { reqId, truncated: true } (cache miss). When truncated, the client automatically resets its sequence number and triggers a full refetch.

Was this page helpful?