Client Store API

Import from svelte-adapter-uws/client. Everything auto-connects - you don’t need to call connect() first.


on(topic) - subscribe to a topic

The main function most users need. Returns a Svelte readable store that updates whenever a message is published to the topic.

Important: The store starts as null (no message received yet). Always use {#if $store} before accessing properties, or you’ll get “Cannot read properties of null”.

<script>
  import { on } from 'svelte-adapter-uws/client';

  // Full event envelope: { topic, event, data }
  const todos = on('todos');
</script>

<!-- ALWAYS guard with {#if} - $todos is null until the first message arrives -->
{#if $todos}
  <p>{$todos.event}: {JSON.stringify($todos.data)}</p>
{/if}

<!-- WRONG - will crash with "Cannot read properties of null" -->
<!-- <p>{$todos.event}</p> -->

on(topic, event) - subscribe to a specific event

Filters to a single event name and wraps the payload in { data }:

<script>
  import { on } from 'svelte-adapter-uws/client';

  // Only 'created' events, wrapped in { data }
  const newTodo = on('todos', 'created');
</script>

{#if $newTodo}
  <p>New todo: {$newTodo.data.text}</p>
{/if}

.scan(initial, reducer) - accumulate state

Like Array.reduce but reactive. Each new event feeds through the reducer:

<script>
  import { on } from 'svelte-adapter-uws/client';

  const todos = on('todos').scan([], (list, { event, data }) => {
    if (event === 'created') return [...list, data];
    if (event === 'updated') return list.map(t => t.id === data.id ? data : t);
    if (event === 'deleted') return list.filter(t => t.id !== data.id);
    return list;
  });
</script>

{#each $todos as todo (todo.id)}
  <p>{todo.text}</p>
{/each}

onDerived(topicFn, store) - reactive topic subscription

Subscribes to a topic derived from a reactive value. When the source store changes, the old topic is released and the new one is subscribed automatically.

<script>
  import { page } from '$app/stores';
  import { onDerived } from 'svelte-adapter-uws/client';
  import { derived } from 'svelte/store';

  // Subscribe to a different topic based on the current route
  const roomId = derived(page, ($page) => $page.params.id);
  const messages = onDerived((id) => `room:${id}`, roomId);
</script>

{#if $messages}
  <p>{$messages.event}: {JSON.stringify($messages.data)}</p>
{/if}

Without onDerived, you’d need to manually watch the source store and call connect().subscribe() / connect().unsubscribe() yourself when it changes. onDerived handles the full lifecycle: subscribes when the first Svelte subscriber arrives, switches topics when the source changes, and unsubscribes from the server when the last Svelte subscriber leaves.


crud(topic, initial?, options?) - live CRUD list

Subscribes to a topic and handles created, updated, and deleted events automatically:

<script>
  import { crud } from 'svelte-adapter-uws/client';

  let { data } = $props(); // from +page.server.js load()

  // $todos auto-updates when server publishes created/updated/deleted
  const todos = crud('todos', data.todos);
</script>

{#each $todos as todo (todo.id)}
  <p>{todo.text}</p>
{/each}

Options:

  • key - property to match items by (default: 'id')
  • prepend - add new items to the beginning instead of end (default: false)
  • maxAge - auto-remove entries that haven’t been created/updated within this many milliseconds (see maxAge below)
// Notifications, newest first
const notifications = crud('notifications', [], { prepend: true });

// Items keyed by 'slug' instead of 'id'
const posts = crud('posts', data.posts, { key: 'slug' });

Pair with platform.topic() on the server:

// Server: +page.server.js
export const actions = {
  create: async ({ request, platform }) => {
    const todo = await db.create(await request.formData());
    platform.topic('todos').created(todo);      // client sees 'created'
  },
  update: async ({ request, platform }) => {
    const todo = await db.update(await request.formData());
    platform.topic('todos').updated(todo);      // client sees 'updated'
  },
  delete: async ({ request, platform }) => {
    await db.delete((await request.formData()).get('id'));
    platform.topic('todos').deleted({ id });    // client sees 'deleted'
  }
};

lookup(topic, initial?, options?) - live keyed object

Like crud() but returns a Record<string, T> instead of an array. Better for dashboards and fast lookups:

<script>
  import { lookup } from 'svelte-adapter-uws/client';

  let { data } = $props();
  const users = lookup('users', data.users);
</script>

{#if $users[selectedId]}
  <UserCard user={$users[selectedId]} />
{/if}

Options:

  • key - property to match items by (default: 'id')
  • maxAge - auto-remove entries that haven’t been created/updated within this many milliseconds (see maxAge below)

maxAge - client-side entry expiry

Both crud() and lookup() accept a maxAge option (in milliseconds). When set, entries that haven’t received a created or updated event within that window are automatically removed from the store. Explicit deleted events still remove entries immediately.

This is useful for state backed by an external store with TTL (e.g. Redis). If the server fails to broadcast a removal event (mass disconnects, crashes, Redis TTL expiry without keyspace notifications), clients clean up on their own:

// Presence entries expire after 90s without a refresh
const users = lookup('__presence:board', data.users, { key: 'key', maxAge: 90_000 });

// Sensor readings expire after 30s without an update
const sensors = lookup('sensors', [], { key: 'id', maxAge: 30_000 });

// Same option works on crud()
const items = crud('items', data.items, { maxAge: 60_000 });

The sweep runs at maxAge / 2 intervals (minimum 1 second). The timer is cleaned up automatically when the last subscriber unsubscribes.


latest(topic, max?, initial?) - ring buffer

Keeps the last N events. Perfect for chat, activity feeds, notifications:

<script>
  import { latest } from 'svelte-adapter-uws/client';

  // Keep the last 100 chat messages
  const messages = latest('chat', 100);
</script>

{#each $messages as msg}
  <p><b>{msg.event}:</b> {msg.data.text}</p>
{/each}

count(topic, initial?) - live counter

Handles set, increment, and decrement events:

<script>
  import { count } from 'svelte-adapter-uws/client';

  const online = count('online-users');
</script>

<p>{$online} users online</p>

Server (from any hook or handler that has platform):

// In hooks.ws.js - track connected users:
export function open(ws, { platform }) {
  platform.topic('online-users').increment();
}
export function close(ws, { platform }) {
  platform.topic('online-users').decrement();
}

// Or from a SvelteKit handler:
platform.topic('online-users').set(42);

Heads up: The increment/decrement pattern above has a subtle race condition - a newly connected client won’t see the current count because its subscribe message hasn’t been processed yet when open fires. See Seeding initial state for the fix.


once(topic, event?, options?) - wait for one event

Returns a promise that resolves with the first matching event and then unsubscribes:

import { once } from 'svelte-adapter-uws/client';

// Wait for any event on the 'jobs' topic
const event = await once('jobs');

// Wait for a specific event
const result = await once('jobs', 'completed');

// With a timeout (rejects if no event within 5 seconds)
const result = await once('jobs', 'completed', { timeout: 5000 });

// Timeout without event filter
const event = await once('jobs', { timeout: 5000 });

ready() - wait for connection

Returns a promise that resolves when the WebSocket connection is open:

import { ready } from 'svelte-adapter-uws/client';

await ready();
// connection is now open, safe to send messages

In SSR (no browser WebSocket), ready() resolves immediately and is a no-op.

ready() rejects if the connection is permanently closed before it opens. This happens when the server sends a terminal close code (1008/4401/4403), retries are exhausted, or close() is called explicitly. If you call ready() in a context where permanent closure is possible, add a .catch() handler or use try/await/catch.


connect(options?) - power-user API

Most users don’t need this - on() and status auto-connect. Use connect() when you need close(), send(), or custom options.

If you pass custom options (like a non-default path), call connect() before any on(), status, ready(), or once() calls. Those functions auto-connect with defaults, and the connection is locked once created. A console warning will fire if your options are ignored due to ordering:

import { connect } from 'svelte-adapter-uws/client';

const ws = connect({
  path: '/ws',               // default: '/ws'
  reconnectInterval: 3000,   // default: 3000 ms
  maxReconnectInterval: 30000, // default: 30000 ms
  maxReconnectAttempts: Infinity, // default: Infinity
  debug: true                // default: false - turn this on to see everything!
});

// With debug: true, you'll see every WebSocket event in the browser console:
//   [ws] connected
//   [ws] subscribe -> todos
//   [ws] <- todos created { id: 1, text: "Buy milk" }
//   [ws] send -> { type: "ping" }
//   [ws] disconnected
//   [ws] queued -> { type: "important" }
//   [ws] resubscribe-batch -> ['todos', 'chat']
//   [ws] flush -> { type: "important" }

// Manual topic management
ws.subscribe('chat');
ws.unsubscribe('chat');

// Send custom messages to the server
ws.send({ type: 'ping' });

// Send with queue (messages queue up while disconnected, flush on reconnect)
ws.sendQueued({ type: 'important', data: '...' });

// Permanent disconnect (won't auto-reconnect)
ws.close();

status - connection state store

Readable store with the current connection state:

<script>
  import { status } from 'svelte-adapter-uws/client';
</script>

{#if $status === 'open'}
  <span class="badge green">Live</span>
{:else if $status === 'connecting'}
  <span class="badge yellow">Connecting...</span>
{:else}
  <span class="badge red">Disconnected</span>
{/if}

close() - close connection

Permanently closes the WebSocket connection. The client will not auto-reconnect after close() is called:

import { close } from 'svelte-adapter-uws/client';

close();

You can also call close() on the object returned by connect():

const ws = connect();
ws.close();

Automatic connection behaviors

The client handles several edge cases automatically, with no configuration required.

Exponential backoff with proportional jitter

Each reconnect attempt waits longer than the previous one. The jitter is +-25% of the base delay (not a fixed +-500ms), so at high attempt counts thousands of clients are spread over a wide window rather than clustering.

Page visibility reconnect

When a browser tab resumes from background or a phone is unlocked, the client reconnects immediately instead of waiting for the backoff timer. Browsers often close WebSocket connections silently when a tab is hidden.

Batch resubscription

On reconnect, all topics are resubscribed in batched subscribe-batch messages. Each batch stays under the server’s 8 KB control-message ceiling and 256-topic-per-batch cap. For typical apps (under 200 topics with short names) this is a single frame; larger sets are automatically chunked.

Zombie detection

The client checks every 30 seconds whether the server has been completely silent for more than 150 seconds (2.5x the server’s idle timeout). If so, it forces a close and reconnects. This catches connections that appear open but were silently dropped by the server, which is common on mobile after wake from sleep.


Message envelope format

All messages use a standard JSON envelope:

{ "topic": "todos", "event": "created", "data": { "id": 1, "text": "Buy milk" } }
  • on(topic) returns the full envelope: { topic, event, data }
  • on(topic, event) filters by event and wraps in { data }
  • crud(), lookup(), latest(), count() handle the envelope automatically

Topic and event names are validated before being written into the JSON envelope - quotes, backslashes, and control characters will throw. This prevents JSON injection when names are built from dynamic values like user IDs.


Terminal close codes

These close codes prevent automatic reconnection:

CodeMeaningBehavior
1008Policy violationNo reconnect
4401UnauthorizedNo reconnect (upgrade rejected)
4403ForbiddenNo reconnect
4429Rate limitedReconnects with aggressive backoff

All other close codes (including 1006 - abnormal closure) trigger the normal exponential backoff reconnection.


Seeding initial state

When a client connects, there’s a window between the WebSocket opening and the client’s topic subscriptions being processed. Any platform.publish() calls during open will be missed by the connecting client because it hasn’t subscribed yet.

The fix is to use the subscribe hook to send the current value directly to the subscribing client:

// src/hooks.ws.js
let online = 0;

export function open(ws, { platform }) {
  online++;
  platform.topic('online').set(online); // broadcasts to already-subscribed clients
}

export function subscribe(ws, topic, { platform }) {
  // When a client subscribes to 'online', send it the current count
  if (topic === 'online') {
    platform.send(ws, 'online', 'set', online);
  }
}

export function close(ws, { platform }) {
  online--;
  platform.topic('online').set(online);
}
<script>
  import { count } from 'svelte-adapter-uws/client';
  const online = count('online');
</script>

<p>{$online} online</p>

The subscribe hook fires at the right moment - after the client is actually subscribed. platform.send() sends only to that one client, so it gets the current value without waiting for the next broadcast.

This same pattern works for CRUD lists where new subscribers need the full dataset:

export async function subscribe(ws, topic, { platform }) {
  if (topic === 'todos') {
    const todos = await db.getTodos();
    for (const todo of todos) {
      platform.send(ws, 'todos', 'created', todo);
    }
  }
}
<script>
  import { crud } from 'svelte-adapter-uws/client';
  // No need for load() data - the subscribe hook seeds the list
  const todos = crud('todos');
</script>

{#each $todos as todo (todo.id)}
  <p>{todo.text}</p>
{/each}

Was this page helpful?