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 functionslive.stream()exports become Svelte storeslive.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:
| Store | Type | Description |
|---|---|---|
status | Readable<'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). |
denials | Readable<{ topic, reason, ref } \| null> | Latest subscribe denial. Reasons: 'UNAUTHENTICATED' \| 'FORBIDDEN' \| 'INVALID_TOPIC' \| 'RATE_LIMITED' or a string from your subscribe hook. |
failure | Readable<{ 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'. |
events | event 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> | Option | Description |
|---|---|
url | Full WebSocket URL for cross-origin or native app usage (e.g. 'wss://api.example.com/ws') |
auth | true (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) |
timeout | Default RPC timeout in ms (default 30_000). Per-call .with({ timeout }) overrides. |
resumeGraceMs | Stream resume-grace window in ms (default 60_000, added 0.5.5). See Resume grace below. |
volatileBackpressureBytes | Backpressure 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:
- Pause/resume UIs work for free. A
{#if active} <SubscribedComponent /> {/if}toggle, or an$effectwhose 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. - 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
versionvalue - 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?