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.

OptionDescription
keyFrom(ctx, ...args)Derive the cache key from ctx and the call args. The framework prepends 'rpc:' + path + ':' automatically.
storeThe idempotency store. See extensions idempotency for backed implementations.
ttlCache TTL in milliseconds.
acquireTtlHow 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 privileged privateRpc.with({ idempotencyKey: 'abc' }) could be replayed by a public publicRpc.with({ idempotencyKey: 'abc' }) and read the cached private result without invoking the public handler. New cap: idempotencyKey longer than 256 chars throws LiveError('INVALID_REQUEST', ...). Custom keyFrom callbacks 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 in batch() 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 exceeds volatileBackpressureBytes (default 4 MB, configurable via configure({ volatileBackpressureBytes })), the frame is dropped silently and __devtools.volatileDropped ticks. Dev-mode emits a one-shot console.warn on 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() (not live.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, and live.public.
  • Authorization model - trust contract for RPC handlers + the mustOwnUser pattern.

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?