RPC - live()
Wrap a server function with live(). Import it in a component. Call it like a regular async function. It runs on the server over WebSocket.
Basic usage
// src/live/todos.js
import { live } from 'svelte-realtime/server';
export const addTodo = live(async (ctx, text) => {
const todo = await db.todos.insert({ text, userId: ctx.user.id });
ctx.publish('todos', 'created', todo);
return todo;
}); <script>
import { addTodo } from '$live/todos';
async function add() {
const todo = await addTodo('Buy milk');
}
</script> The ctx object
Every live() function receives ctx as its first argument. See The ctx Object for the full reference.
export const greet = live((ctx) => {
return `Hello, ${ctx.user.name}`;
}); Multiple arguments
Arguments after ctx are passed from the client:
export const move = live(async (ctx, x, y) => {
ctx.publish('positions', 'update', { key: ctx.user.id, x, y });
}); <script>
import { move } from '$live/game';
await move(100, 200);
</script> Error handling
Throw LiveError on the server. Catch RpcError on the client.
// Server
import { live, LiveError } from 'svelte-realtime/server';
export const deleteItem = live(async (ctx, id) => {
if (!ctx.user) throw new LiveError('UNAUTHORIZED', 'Login required');
await db.items.delete(id);
}); <!-- Client -->
<script>
import { deleteItem } from '$live/items';
try {
await deleteItem(itemId);
} catch (err) {
console.log(err.code); // 'UNAUTHORIZED'
console.log(err.message); // 'Login required'
}
</script> Schema validation
Use live.validated() to validate the first argument before the function runs. Any Standard Schema-compatible validator is supported, including Zod, ArkType, Valibot, and others.
import { z } from 'zod';
import { live } from 'svelte-realtime/server';
const CreateTodo = z.object({
text: z.string().min(1).max(200),
priority: z.enum(['low', 'medium', 'high']).optional()
});
export const addTodo = live.validated(CreateTodo, async (ctx, input) => {
const todo = await db.todos.insert({ ...input, userId: ctx.user.id });
ctx.publish('todos', 'created', todo);
return todo;
}); Because live.validated() uses the Standard Schema interface, you can swap in any compatible validator without changing the call site:
import { type } from 'arktype';
const CreateTodo = type({
text: 'string>0',
'priority?': '"low"|"medium"|"high"'
});
export const addTodo = live.validated(CreateTodo, async (ctx, input) => {
// input is fully typed from the schema
const todo = await db.todos.insert({ ...input, userId: ctx.user.id });
ctx.publish('todos', 'created', todo);
return todo;
}); Validation errors throw RpcError with code: 'VALIDATION' and an issues array.
Request deduplication
Identical calls in the same microtask are coalesced:
const [a, b] = await Promise.all([
getUser(userId),
getUser(userId) // reuses the first request
]); Force a fresh request with .fresh():
const result = await getUser.fresh(userId); Idempotent RPCs - live.idempotent
live.idempotent() wraps an RPC so that retries with the same idempotencyKey resolve to the cached result without re-running the handler. Combined with the extensions idempotency store, the cache survives process restarts.
import { live } from 'svelte-realtime/server';
export const charge = live.idempotent(
async (ctx, amount, currency) => {
const receipt = await stripe.charges.create({ amount, currency, customer: ctx.user.id });
return receipt;
},
{
keyFrom: (ctx, amount, currency) => `${ctx.user.id}:${amount}:${currency}`,
store: idempotencyStore
}
); The client supplies an idempotency key explicitly via .with({ idempotencyKey }):
<script>
import { charge } from '$live/billing';
const requestId = crypto.randomUUID();
const receipt = await charge.with({ idempotencyKey: requestId })(2500, 'usd');
</script> store is any object with acquire(key) returning { status: 'acquired' | 'pending' | 'result', result?, release? }. The bundled in-memory store works for single-instance deployments; Redis and Postgres stores from the extensions package work cluster-wide.
| Option | Description |
|---|---|
keyFrom(ctx, ...args) | Derive the cache key from ctx and the call args. The framework prepends 'rpc:' + path + ':' automatically. |
store | The idempotency store. See extensions idempotency for backed implementations. |
ttl | Cache TTL in milliseconds. |
acquireTtl | How long another caller waits for an in-flight key. |
Upgrading from 0.4.x: The cache key sent to
store.acquire(...)is now'rpc:' + path + ':' + userKey. In-flight cache entries from before this release become invisible after deploy because the namespaced key does not match the old un-namespaced key. Pre-0.5, a privilegedprivateRpc.with({ idempotencyKey: 'abc' })could be replayed by a publicpublicRpc.with({ idempotencyKey: 'abc' })and read the cached private result without invoking the public handler. New cap:idempotencyKeylonger than 256 chars throwsLiveError('INVALID_REQUEST', ...). CustomkeyFromcallbacks must still encode tenant scope explicitly. See Migration 0.4 to 0.5.
Batching
Group multiple calls into a single WebSocket frame:
<script>
import { batch } from 'svelte-realtime/client';
import { createBoard, addColumn } from '$live/boards';
const [board, column] = await batch(() => [
createBoard('My Board'),
addColumn('To Do')
]);
</script> Pass { sequential: true } when order matters. Each call resolves independently - one failure doesn’t cancel the others. Max 50 calls per batch.
Batch of 1 sends a bare frame (0.5.8). When the batch collects exactly one call, the client writes the bare
{rpc, id, args}frame instead of a batch envelope and the server replies through the normal RPC path. The defensive “always wrap writes inbatch()for symmetry” pattern pays no envelope overhead at the single-call site. Batches of 2+ keep the envelope.
Volatile RPC (fire-and-forget)
For high-frequency one-way calls where the caller has no reply to await - cursor moves, drag updates, typing indicators, telemetry beacons, heartbeats - mark the handler with live.volatile(fn) server-side and call .fireAndForget(...args) client-side. The wire frame carries no id; the server runs the full handler chain (middleware, guards, rate limits, validation) but does not write a response.
// src/live/cursors.js
import { live } from 'svelte-realtime/server';
export const moveCursor = live.volatile(async (ctx, boardId, pos) => {
ctx.publish(`board:${boardId}`, 'cursor', pos);
}); <script>
import { moveCursor } from '$live/cursors';
function onPointerMove(e) {
moveCursor.fireAndForget(boardId, { x: e.clientX, y: e.clientY });
// returns void synchronously - no Promise, no await
}
</script> What .fireAndForget() skips that a normal RPC does:
- ID allocation
- Promise allocation
- Dedup-Map entry +
queueMicrotask(delete) - Pending-Map entry
- Per-call 30 s timeout timer
- Devtools-pending entry
At 60-120Hz on a single hot path that is 100K+ short-lived heap allocations per second avoided on the client.
Safety contract
- Errors disappear silently from the caller. A volatile call that fails auth, validation, or throws still runs through metrics and server logs - operators see the failure - but the wire carries no reply, so the caller does not. Use
live.volatile()only when this is the intended contract. - Backpressure drop. Before send, the client reads
WS.bufferedAmount; if it exceedsvolatileBackpressureBytes(default 4 MB, configurable viaconfigure({ volatileBackpressureBytes })), the frame is dropped silently and__devtools.volatileDroppedticks. Dev-mode emits a one-shotconsole.warnon first drop per session. - Offline drop. Volatile calls made while disconnected are silently dropped. They do not enter the offline queue (which is for awaited mutations).
- Inside
batch(). Throws in dev, no-op in prod. Volatile bypasses batching by design.
Server-side marker
The marker (live.volatile(fn)) is recommended, not required. The wire shape (id absent) is the actual contract. A .fireAndForget() against a plain live() handler also works - server processes it, just skips the reply - but dev-mode emits a one-shot warning per such path so accidental fire-and-forget surfaces. Mark intentional one-way handlers with live.volatile() to silence the warning and document intent. The marker can sit at any depth inside live.rateLimit / live.idempotent / live.breaker / live.validated / live.lock wrappers.
When NOT to use .fireAndForget()
- The caller needs to know whether the call succeeded -> use the normal awaited RPC.
- The call needs to be retried on failure -> use
.with({ idempotencyKey })+ normal RPC. - The call should survive a disconnect -> use the offline queue via the normal RPC.
- The call is sometimes one-way, sometimes interesting -> keep the handler
live()(notlive.volatile()) and choose at each call site.
live.notify vs .fireAndForget()
Both are fire-and-forget, but they go in opposite directions: live.notify(target, event, data) is server -> client (server-initiated push, no client reply expected), while .fireAndForget(...args) is client -> server (client-initiated RPC, no server reply emitted). Different surfaces, different use cases.
See also
- Auth - guards, access predicates,
live.scoped, andlive.public. - Authorization model - trust contract for RPC handlers + the
mustOwnUserpattern.
Under the hood, RPC calls are routed over the adapter’s WebSocket connection. See svelte-adapter-uws for the transport layer.
Was this page helpful?