A SvelteKit adapter powered by uWebSockets.js - the fastest HTTP/WebSocket server available for Node.js, written in C++ and exposed through V8.
I've been loving Svelte and SvelteKit for a long time. I always wanted to expand on the standard adapters, sifting through the internet from time to time, never finding what I was searching for - a proper high-performance adapter with first-class WebSocket support, native TLS, pub/sub built in, and a client library that just works. So I'm doing it myself.
SSLApp, no reverse proxy needed/healthz out of the boxwebsocket: true and goUpgrading from 0.4.x? See the migration guide for every breaking change between 0.4.x and 0.5.x.
Getting started
Configuration
WebSocket deep dive
hooks.ws)event.platform)Plugins
Deployment & scaling
Examples
Help
Getting started
The three ecosystem packages move together. Bump them as a group:
svelte-adapter-uws |
svelte-realtime |
svelte-adapter-uws-extensions |
Notes |
|---|---|---|---|
^0.4.x |
^0.4.x |
^0.4.x |
Legacy stable |
^0.5.0 |
^0.5.0 |
^0.5.0 |
Current. Node 22+ required. See MIGRATION.md if upgrading from 0.4. |
Mixed-version installs are rejected at install time with a peer-dep warning.
If you don't have a SvelteKit project yet:
npx sv create my-app
cd my-app
npm install
npm install svelte-adapter-uws
npm install uNetworking/uWebSockets.js#v20.60.0
Note: uWebSockets.js is a native C++ addon installed directly from GitHub, not from npm. It may not compile on all platforms. Check the uWebSockets.js README if you have issues.
Docker: Use
node:22-trixie-slimor another glibc >= 2.38 image. Bookworm-based images and Alpine won't work. See Deploying with Docker.
If you plan to use WebSockets during development, also install ws:
npm install -D ws
The simplest setup - just swap the adapter and you're done.
svelte.config.js
import adapter from 'svelte-adapter-uws';
export default {
kit: {
adapter: adapter()
}
};
Build and run:
npm run build
node build
Your app is now running on http://localhost:3000.
To change the host or port:
HOST=0.0.0.0 PORT=8080 node build
No reverse proxy needed. uWebSockets.js handles TLS natively with its SSLApp.
svelte.config.js - same as HTTP, no changes needed:
import adapter from 'svelte-adapter-uws';
export default {
kit: {
adapter: adapter()
}
};
Build and run with TLS:
npm run build
SSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build
Your app is now running on https://localhost:3000.
Both
SSL_CERTandSSL_KEYmust be set. Setting only one will throw an error.
If your proxy terminates TLS and forwards to HTTP:
ORIGIN=https://example.com node build
Or if you want flexible header-based detection:
PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host node build
Important:
PROTOCOL_HEADER,HOST_HEADER,PORT_HEADER, andADDRESS_HEADERare trusted verbatim. Only set these when running behind a reverse proxy that overwrites the corresponding headers on every request. If the server is directly internet-facing, clients can spoof these values. When in doubt, use a fixedORIGINinstead.
Three things to do:
svelte.config.js
import adapter from 'svelte-adapter-uws';
export default {
kit: {
adapter: adapter({
websocket: true
})
}
};
That's it. This gives you a pub/sub WebSocket server at /ws with no authentication. Any client can connect, subscribe to topics, and receive messages.
The Vite plugin is required when using WebSockets. It does two things:
event.platform works during npm run devhooks.ws file through Vite's pipeline so $lib, $env, and $app imports resolve correctlyWithout it, your hooks.ws file won't be able to import from $lib or use $env variables, and event.platform won't work in dev.
vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import uws from 'svelte-adapter-uws/vite';
export default {
plugins: [sveltekit(), uws()]
};
src/routes/+page.svelte
<script>
import { on, status } from 'svelte-adapter-uws/client';
// Subscribe to the 'notifications' topic
// Auto-connects, auto-subscribes, auto-reconnects
const notifications = on('notifications');
</script>
{#if $status === 'open'}
<span>Connected</span>
{/if}
{#if $notifications}
<p>Event: {$notifications.event}</p>
<p>Data: {JSON.stringify($notifications.data)}</p>
{/if}
src/routes/api/notify/+server.js
export async function POST({ request, platform }) {
const data = await request.json();
// This sends to ALL clients subscribed to 'notifications'
platform.publish('notifications', 'new-message', data);
return new Response('OK');
}
Build and run:
npm run build
node build
WSS works automatically when you enable TLS. WebSocket connections upgrade over the same HTTPS port.
svelte.config.js
import adapter from 'svelte-adapter-uws';
export default {
kit: {
adapter: adapter({
websocket: true
})
}
};
npm run build
SSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build
The client store automatically uses wss:// when the page is served over HTTPS - no configuration needed on the client side.
npm run dev - works (with the Vite plugin)The Vite plugin is required for WebSocket support in both dev and production (see Step 2). It spins up a ws WebSocket server alongside Vite's dev server, so your client store and event.platform work identically to production.
Changes to your hooks.ws file are picked up automatically - the plugin reloads the handler on save and closes existing connections so they reconnect with the new code. No dev server restart needed.
Note: The dev plugin enforces allowedOrigins on WebSocket upgrades the same way the production handler does. For local dev scenarios that need to accept arbitrary origins (e.g. WSS from a staging client during integration), pass devSkipOriginCheck: true to the plugin: uws({ devSkipOriginCheck: true }).
vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import uws from 'svelte-adapter-uws/vite';
export default {
plugins: [sveltekit(), uws()]
};
npm run preview - WebSockets don't workSvelteKit's preview server is Vite's built-in HTTP server. It doesn't know about uWebSockets.js or WebSocket upgrades. Your HTTP routes and SSR will work, but WebSocket connections will fail.
Use node build instead of preview for testing WebSocket features.
node build - production, everything worksThis is the real deal. uWebSockets.js handles everything:
npm run build
node build
Or with environment variables:
PORT=8080 HOST=0.0.0.0 node build
Or with TLS:
SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 node build
Configuration
adapter({
// Output directory for the build
out: 'build', // default: 'build'
// Precompress static assets with brotli and gzip
precompress: true, // default: true
// Prefix for environment variables (e.g. 'MY_APP_' -> MY_APP_PORT)
envPrefix: '', // default: ''
// Health check endpoint (set to false to disable)
healthCheckPath: '/healthz', // default: '/healthz'
// WebSocket configuration
websocket: true // or false, or an options object (see below)
})
adapter({
websocket: {
// Path for WebSocket connections
path: '/ws', // default: '/ws'
// Path to your custom handler module (auto-discovers src/hooks.ws.js if omitted)
handler: './src/lib/server/websocket.js', // default: auto-discover
// Max message size in bytes (connections sending larger messages are closed)
maxPayloadLength: 1024 * 1024, // default: 1 MB
// Seconds of inactivity before the connection is closed
idleTimeout: 120, // default: 120
// Max bytes of backpressure per connection before messages are dropped.
// uWS defaults to 64 KB; this adapter uses 1 MB to handle pub/sub spikes.
// Lower this if you expect many slow consumers.
maxBackpressure: 1024 * 1024, // default: 1 MB
// Enable per-message deflate compression
compression: false, // default: false
// Automatically send pings to keep the connection alive
sendPingsAutomatically: true, // default: true
// Seconds before an async upgrade handler is rejected with 504 (0 to disable)
upgradeTimeout: 10, // default: 10
// Sliding-window rate limit: max WebSocket upgrade requests per IP per window.
// Prevents connection flood attacks. Uses a sliding window so a client cannot
// double the effective rate by placing requests at a fixed-window boundary.
// Set to 0 to disable.
upgradeRateLimit: 10, // default: 10
upgradeRateLimitWindow: 10, // window size in seconds, default: 10
// Allowed origins for WebSocket connections
// 'same-origin' - only accept where Origin matches Host and scheme (default)
// '*' - accept from any origin
// ['https://example.com'] - whitelist specific origins
// Requests without an Origin header (non-browser clients) are rejected
// unless an upgrade handler is configured to authenticate them.
allowedOrigins: 'same-origin' // default: 'same-origin'
}
})
These options control how the server handles misbehaving or slow clients at the WebSocket level:
maxPayloadLength (default: 1 MB) - the maximum size of a single incoming WebSocket message. If a client sends a message larger than this, uWS closes the connection immediately (not just the message - the entire connection is dropped). Set this based on the largest message your application expects to receive. uWS's own default is 16 KB, which the adapter previously matched; the 1 MB default ships now to handle typical app payloads in a single frame without forcing chunked-upload frameworks into ~12 KB chunks (which the previous 16 KB cap did). For a stricter cap, pin an explicit value (e.g. 16 * 1024 for the uWS-matching 16 KB).
maxBackpressure (default: 1 MB) - the per-connection outbound send buffer, AND the threshold above which publish / send / publishBatched silently skip a subscriber. When a specific subscriber's buffer is over this size, uWS drops that frame for that subscriber only while continuing to deliver to every non-backpressured subscriber. This makes publish / send / publishBatched volatile-by-default for slow consumers (the right behavior for cursor positions, typing indicators, presence pings - see "Volatile / fire-and-forget delivery" below). The drain hook fires per-connection when the buffer empties again. Lower this if you want subscribers shed sooner; raise it if you prefer to keep the connection queued and absorb temporary slowness. uWS's own default is 64 KB; this adapter sets 1 MB to favor keeping the connection alive under pub/sub spikes.
upgradeRateLimit (default: 10 per 10s window) - sliding-window rate limit on WebSocket upgrade requests per client IP. Clients exceeding the limit get a 429 Too Many Requests response. The IP rate map is capped at 10,000 entries with LRU eviction by activity score, so sustained connection floods from many IPs don't cause unbounded memory growth.
upgradeAdmission (default: disabled) - two-layer admission control on the upgrade path, both opt-in:
maxConcurrent caps how many upgrades may be in flight at once. Crossed requests get a fast 503 Service Unavailable before any per-request work, so a connection storm can be shed without spending CPU on TLS, header parsing, or cookie decoding. Set this just above your steady-state in-flight count to act as a circuit breaker.perTickBudget caps how many actual res.upgrade() calls run per Node.js event-loop tick. Once the budget is spent, subsequent calls are deferred via setImmediate so the loop is not starved by 10K synchronous handshakes from one I/O batch. Pre-upgrade work (rate limit, origin check, hook dispatch) still runs in the original tick; only the hand-off to the C++ upgrade path is paced. Start with 64 and adjust based on your peak burst envelope.adapter({
websocket: {
upgradeAdmission: { maxConcurrent: 1000, perTickBudget: 64 }
}
});
The two layers are independent: each works without the other. Both default to 0 (disabled) so the upgrade path stays unchanged unless you opt in.
upgradeAdmission operates at the WebSocket handshake. It sheds connection attempts before TLS work and before any per-request CPU is spent. That is the right primitive when the threat is "too many clients are trying to connect" - a connection flood, a thundering herd after a deploy, a runaway client retry loop.
It is NOT the right primitive when the threat is "established connections are sending too many RPCs" - a chatty client, an abusive presence ping loop, a misbehaving game tick. Those calls have already passed the handshake; the connection is open; you want to shed at the message dispatch layer instead.
For that second layer, svelte-adapter-uws-extensions ships createAdmissionControl, an opt-in message-path admission wrapper that runs against already-accepted connections. The two stack naturally:
// Production wiring sketch
import { createAdmissionControl } from 'svelte-adapter-uws-extensions';
const messageAdmission = createAdmissionControl({ /* RPC concurrency, per-key buckets, ... */ });
// In hooks.ws.js
export function message(ws, ctx) {
messageAdmission.run(ws, ctx, async () => {
// ... your message handler ...
});
}
// In svelte.config.js
adapter({
websocket: {
upgradeAdmission: { maxConcurrent: 1000, perTickBudget: 64 } // handshake layer
}
});
The two layers do not share state, configuration, or call sites. They cannot drift apart because the WebSocket lifecycle enforces the ordering: a connection that fails upgradeAdmission never reaches the message handler at all, so createAdmissionControl only ever sees connections that were already admitted at the handshake. The layering is a structural property, not a runtime one.
Defense-in-depth opt-ins layered on top of allowedOrigins. All default to safe values; flip them only after the documented audit step.
websocket.authPathRequireOrigin (default true) - the /__ws/auth POST endpoint requires x-requested-with: XMLHttpRequest, Sec-Fetch-Site: same-origin, or an Origin matching allowedOrigins. The adapter client always stamps x-requested-with so the browser path is unaffected. Set false to accept native (non-browser) clients without those headers.websocket.compressCredentialedResponses (default false) - requests carrying Cookie or Authorization skip dynamic brotli/gzip compression to defend against the BREACH attack (compressed length leaks attacker-influenced reflected input alongside a secret). Set true only after auditing the page surface for BREACH defenses (random per-response masking, prefix randomization, no secrets reflected with attacker input). Build-time precompressed static files are unaffected.websocket.unsafeSameOriginWithoutHostPin (default false) - when allowedOrigins: 'same-origin' is paired with no fronting trust (no ORIGIN env, no HOST_HEADER env, no native TLS, no upgrade() hook), the runtime throws at startup because the same-origin check then compares two attacker-controlled headers (Origin vs Host). Set true to restore the previous warn-only behavior. Pin the deployment shape first (ORIGIN, HOST_HEADER, native TLS, or an upgrade() hook).websocket.allowSystemTopicSubscribe (default false) and websocket.allowNonAsciiTopics (default false) are documented in Topic validation. The Vite plugin mirrors all of these flags; devSkipOriginCheck (default false) on the plugin disables the dev-mode allowedOrigins enforcement for local-only scenarios.
Every internal Map / Set that grows with client behaviour or topic cardinality has an explicit upper bound and a defined behaviour at saturation. The defaults are deliberately generous (1,000,000 across the board) - far above any healthy single-connection use, even at uWS's million-connection scale - so the cap catches obvious bugs and runaway clients without ever biting real apps. Aggregate memory at extreme scale is bounded separately by upgradeAdmission.maxConcurrent; per-connection caps are not the right place to defend against a 1M-connection DoS.
| Site | Default cap | Behaviour at saturation | Override |
|---|---|---|---|
| Subscriptions per connection | 1,000,000 | subscribe-denied with reason 'RATE_LIMITED' |
not exposed |
Pending platform.request calls per connection |
1,000,000 | promise rejects with "pending requests exceeded" | not exposed |
sendCoalesced keys per connection |
1,000,000 | drop oldest insertion-order entry on insert | not exposed |
Topic seq registry (topicSeqs) |
1,000,000 | one structured console.warn with topN publishers; publish continues |
not exposed (resume protocol depends on persistence) |
| Runaway-publisher warn dedup | 1,000,000 | FIFO-evict oldest entry on insert | not exposed |
envelopePrefixCache |
256 | FIFO half-evict | not exposed |
decodeCache |
256 | FIFO half-evict | not exposed |
| SSR dedup in-flight | 500 | new request bypasses dedup | not exposed |
| SSR dedup body buffer per request | 512 KB | response replays without dedup | not exposed |
| Upgrade rate-limit IP map | 10,000 | LRU on 60s sweep | not exposed |
| Aggregate live connections | unbounded by default | reject upgrade with 503 once maxConcurrent set |
upgradeAdmission.maxConcurrent |
| Outbound buffer per connection | 1 MB | uWS drops the frame for that subscriber only | wsOptions.maxBackpressure |
Plugin caps all default to 1,000,000 with the same idiot-proof bias:
| Plugin | Cap | Behaviour at saturation | Override |
|---|---|---|---|
replay |
maxTopics: 100, ring size: 1000 |
LRU evict / ring overwrite | per-topic options |
presence |
maxConnections: 1_000_000, maxTopics: 1_000_000 |
drop oldest insertion-order entry | constructor options |
cursor |
maxConnections: 1_000_000, maxTopics: 1_000_000 |
drop oldest insertion-order entry; pending throttle timers cleared | constructor options |
throttle / debounce |
maxTopics: 1_000_000 |
flush pending then drop oldest topic | second arg to throttle(interval, options) / debounce(...) |
lock |
maxKeys: 1_000_000 |
new-key withLock rejects with "active key count exceeded" |
constructor options |
ratelimit |
maxBuckets: 1_000_000 |
drop oldest insertion-order bucket on insert | constructor options |
queue |
maxSize: 1_000_000 per key |
push rejects, onDrop callback fires |
constructor options (pass Infinity to opt out) |
dedup |
maxEntries: 10_000 |
soft + hard cap, oldest insertion-order evicted | constructor options |
session |
maxEntries: 10_000 |
soft + hard cap, oldest insertion-order evicted | constructor options |
groups |
maxMembers (per group, required) |
join returns false, onFull callback fires |
required option |
Two policy notes:
1,000,000 subscriptions × 1,000,000 connections is more than any realistic process can handle. The per-conn caps catch single-connection bugs (a for (i=0; i<N; i++) ws.subscribe('topic-' + i) loop, a misbehaving extension); they do not pretend to OOM-protect a 1M-connection server. Set upgradeAdmission.maxConcurrent for that.topicSeqs is warn-only. The seq registry cannot evict entries - the resume protocol depends on each topic's monotonic counter persisting for the process lifetime, and dropping a row would corrupt any reconnecting client trying to resume that topic. The cap fires a single structured console.warn with the topN recent publishers when the threshold is first crossed; ops sees the leak shape and can reduce topic cardinality (or opt out with { seq: false } per publish) before OOM.All static assets (from the client/ and prerendered/ output directories) are loaded once at startup and served directly from RAM. Each response automatically includes:
Content-Type: detected from the file extensionVary: Accept-Encoding: required for correct CDN/proxy caching when serving precompressed variantsAccept-Ranges: bytes: enables partial content requests (e.g. for download resume)X-Content-Type-Options: nosniff: prevents MIME-type sniffing in browsersETag: derived from the file's modification time and size; enables 304 Not Modified responsesCache-Control: public, max-age=31536000, immutable: for versioned assets under /_app/immutable/Cache-Control: no-cache: for all other assets (forces ETag revalidation)Range requests (HTTP 206): The server handles Range: bytes=start-end requests for static files. Single byte ranges are supported (bytes=0-499, bytes=-500, bytes=500-). Multi-range requests (comma-separated) are served as full 200 responses. An unsatisfiable range returns 416 Range Not Satisfiable. When a Range header is present, the response is always served uncompressed so byte offsets are correct. The If-Range header is respected: if it doesn't match the file's ETag, the full file is returned.
Files with extensions that browsers cannot render inline (.zip, .tar, .tgz, .exe, .dmg, .pkg, .deb, .apk, .iso, .img, .bin, etc.) automatically receive Content-Disposition: attachment so browsers prompt a download dialog instead of attempting to display them.
If precompress: true is set in the adapter options, brotli (.br) and gzip (.gz) precompressed variants are loaded at startup and served when the client's Accept-Encoding header includes br or gzip. Precompressed variants are only used when they are smaller than the original file.
All variables are set at runtime (when you run node build), not at build time.
If you set envPrefix: 'MY_APP_' in the adapter config, all variables are prefixed (e.g. MY_APP_PORT instead of PORT).
| Variable | Default | Description |
|---|---|---|
HOST |
0.0.0.0 |
Bind address |
PORT |
3000 |
Listen port |
ORIGIN |
(derived) | Fixed origin (e.g. https://example.com) |
SSL_CERT |
- | Path to TLS certificate file |
SSL_KEY |
- | Path to TLS private key file |
PROTOCOL_HEADER |
- | Header for protocol detection (e.g. x-forwarded-proto) |
HOST_HEADER |
- | Header for host detection (e.g. x-forwarded-host) |
PORT_HEADER |
- | Header for port override (e.g. x-forwarded-port) |
ADDRESS_HEADER |
- | Header for client IP (e.g. x-forwarded-for) |
XFF_DEPTH |
1 |
Position from right in X-Forwarded-For |
BODY_SIZE_LIMIT |
512K |
Max request body size (supports K, M, G suffixes) |
SHUTDOWN_TIMEOUT |
30 |
Seconds to wait during graceful shutdown |
CLUSTER_WORKERS |
- | Number of worker threads (or auto for CPU count) |
CLUSTER_MODE |
(auto) | reuseport (Linux default) or acceptor (other platforms) |
WS_DEBUG |
- | Set to 1 to enable structured WebSocket debug logging (open, close, subscribe, publish) |
On SIGTERM or SIGINT, the server:
SHUTDOWN_TIMEOUT seconds)sveltekit:shutdown event on process (for cleanup hooks like closing database connections)// Listen for shutdown in your server code (e.g. hooks.server.js)
process.on('sveltekit:shutdown', async (reason) => {
console.log(`Shutting down: ${reason}`);
await db.close();
});
# Simple HTTP
node build
# Custom port
PORT=8080 node build
# Behind nginx
ORIGIN=https://example.com node build
# Behind a proxy with forwarded headers
PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host ADDRESS_HEADER=x-forwarded-for node build
# Native TLS
SSL_CERT=./cert.pem SSL_KEY=./key.pem node build
# Everything at once
SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 HOST=0.0.0.0 BODY_SIZE_LIMIT=10M SHUTDOWN_TIMEOUT=60 node build
Add the platform type to your src/app.d.ts:
import type { Platform as AdapterPlatform } from 'svelte-adapter-uws';
declare global {
namespace App {
interface Platform extends AdapterPlatform {}
}
}
export {};
Now event.platform.publish(), event.platform.topic(), etc. are fully typed.
This adapter supports both Svelte 4 and Svelte 5. All examples in this README use Svelte 5 syntax ($props(), runes). If you're on Svelte 4, here's how to translate:
Svelte 5 (used in examples)
<script>
import { crud } from 'svelte-adapter-uws/client';
let { data } = $props();
const todos = crud('todos', data.todos);
</script>
Svelte 4 equivalent
<script>
import { crud } from 'svelte-adapter-uws/client';
export let data;
const todos = crud('todos', data.todos);
</script>
The only difference is how you receive props. The client store API (on, crud, lookup, latest, count, once, status, connect) works identically in both versions - it uses svelte/store which hasn't changed.
WebSocket deep dive
hooks.ws)With websocket: true, a built-in handler accepts all connections and handles subscribe/unsubscribe messages from the client store. No file needed.
Note:
websocket: trueonly sets up the server side. To actually receive messages in the browser, you need to import the client store (on,crud, etc.) in your Svelte components. Without the client store, the WebSocket endpoint exists but nothing connects to it.
Create src/hooks.ws.js (or .ts, .mjs) and it will be automatically discovered - no config needed:
src/hooks.ws.js
// Called during the HTTP -> WebSocket upgrade handshake.
// Return an object to accept (becomes ws.getUserData()).
// Return false to reject with 401.
// Omit this export to accept all connections.
export async function upgrade({ headers, cookies, url, remoteAddress }) {
const sessionId = cookies.session_id;
if (!sessionId) return false;
const user = await validateSession(sessionId);
if (!user) return false;
// Whatever you return here is available as ws.getUserData()
return { userId: user.id, name: user.name };
}
// Called when a connection is established
export function open(ws, { platform }) {
const { userId } = ws.getUserData();
console.log(`User ${userId} connected`);
// Subscribe this connection to a user-specific topic
ws.subscribe(`user:${userId}`);
}
// Called when a message is received.
// Note: subscribe/unsubscribe messages from the client store are
// handled automatically BEFORE this function is called.
//
// `msg` is the JSON-parsed envelope when the adapter parsed the frame
// for control-message routing but no control type matched (i.e. it
// looks like `{"type":"<custom>",...}` from a plugin). The adapter
// already did `TextDecoder + JSON.parse` once during routing, so this
// avoids a second parse on the dispatch path. `msg` is `undefined`
// for binary frames, prefix-miss frames, parse failures, or frames
// that parse to a non-object.
export function message(ws, { data, isBinary, msg }) {
if (msg) {
// Already-parsed JSON object envelope - dispatch by msg.type
console.log('Got envelope:', msg);
return;
}
// Binary or non-envelope text frame - decode manually
console.log('Got raw frame, byteLength:', data.byteLength);
}
// Called when a client tries to subscribe to a topic (optional)
// Return false to deny the subscription
export function subscribe(ws, topic, { platform }) {
const { role } = ws.getUserData();
// Only admins can subscribe to admin topics
if (topic.startsWith('admin') && role !== 'admin') return false;
}
// Called when a client unsubscribes from a topic (optional)
// Use this to clean up per-topic state (presence, groups, etc.)
export function unsubscribe(ws, topic, { platform }) {
console.log(`Unsubscribed from ${topic}`);
}
// Called when the connection closes. The context carries per-connection
// stats (id / duration / messagesIn / messagesOut / bytesIn / bytesOut)
// alongside `code` / `message` / `subscriptions`. Counters are only
// populated when this hook is exported - the adapter skips the
// per-connection bookkeeping otherwise to keep the hot path zero-cost.
export function close(ws, { code, id, duration, messagesIn, messagesOut, bytesIn, bytesOut, subscriptions }) {
const { userId } = ws.getUserData();
console.log(
`User ${userId} (session ${id}) disconnected after ${duration}ms ` +
`(${messagesIn} in / ${messagesOut} out, ${bytesIn} / ${bytesOut} bytes, ` +
`topics: ${[...subscriptions].join(', ')})`
);
}
// Called when backpressure has drained (optional, for flow control)
export function drain(ws, { platform }) {
// You can resume sending large messages here
}
// Called when a reconnecting client presents the previous session id
// plus the per-topic seq numbers it last saw. Use this to fill the
// disconnect gap, typically by replaying buffered events. Optional -
// without this hook, reconnects still work; the client just falls
// through to live mode without a gap fill.
export function resume(ws, { sessionId, lastSeenSeqs, platform }) {
for (const [topic, sinceSeq] of Object.entries(lastSeenSeqs)) {
replay.replay(ws, topic, sinceSeq, platform);
}
}
On every WS open, the server stamps a session id and announces it to the client ({"type":"welcome","sessionId":"..."}). The client stores the id in sessionStorage (keyed per ws path) and tracks the highest seq it has seen for each topic.
When the connection drops and the client reconnects, it presents the previous session id plus the per-topic last-seen seqs in a {"type":"resume", sessionId, lastSeenSeqs} frame, sent before subscribe-batch. If you export a resume hook, you receive (ws, { sessionId, lastSeenSeqs, platform }) and can replay any events the client missed during the disconnect window. The server acks with {"type":"resumed"} once your hook returns; the client then resubscribes and live messages resume.
Without a resume hook the protocol is still safe: the server acks the resume frame, the client falls through to live mode, and your app behaves the same as a cold connect.
The session id is per-process and per-connection. It does not persist across server restarts; a client presenting a session id the server has never seen receives the same resumed ack and falls through.
When the client subscribes, it includes a numeric ref so the server can ack with the result:
{"type":"subscribed", topic, ref} - subscription accepted.{"type":"subscribe-denied", topic, ref, reason} - subscription rejected. reason is one of the canonical codes 'UNAUTHENTICATED', 'FORBIDDEN', 'INVALID_TOPIC', 'RATE_LIMITED', or any custom string the server's subscribe hook returned.The denial is surfaced on the client through the denials store. Show it as a banner, route to a login page, anything you like:
<script>
import { denials } from 'svelte-adapter-uws/client';
</script>
{#if $denials}
<p class="error">Cannot subscribe to {$denials.topic}: {$denials.reason}</p>
{/if}
The server's subscribe hook controls denial reasons:
export function subscribe(ws, topic, { platform }) {
const { userId, role } = ws.getUserData();
if (!userId) return 'UNAUTHENTICATED'; // -> subscribe-denied
if (topic.startsWith('admin') && role !== 'admin') {
return 'FORBIDDEN';
}
// omit / return undefined / return true -> subscribed
}
Old clients that send subscribe without a ref get no ack frame (silent allow / silent deny, as before). Old servers that ignore ref don't break new clients - they just don't emit acks; the client sees no entry in denials and treats the subscription as active.
subscribe-batch works the same way: one ack frame per topic in the batch, all sharing the batch's single ref.
For batch subscribes (typically the resubscribe-on-reconnect path) you can opt into a single-call subscribeBatch hook instead of paying N subscribe calls. The framework calls it once with all pre-validated topics and applies the returned per-topic decisions:
export async function subscribeBatch(ws, topics, { platform }) {
const { userId } = ws.getUserData();
// One DB query for all topics instead of N
const allowed = await db.allowedTopics(userId, topics);
const allowedSet = new Set(allowed);
const denials = {};
for (const topic of topics) {
if (!allowedSet.has(topic)) denials[topic] = 'FORBIDDEN';
}
return denials; // omit a topic -> allow; false -> FORBIDDEN; string -> that reason
}
If you only export subscribe, the framework still loops it per topic for batch-subscribes (no behaviour change). Export subscribeBatch only when you need the single-query optimization. The hook is sync in this version; for async lookups, pre-cache user grants on userData during upgrade.
The adapter uses a JSON envelope format for all pub/sub messages: { topic, event, data, seq? }. Control messages from the client store (subscribe, unsubscribe, subscribe-batch, resume) use { type, topic, ref? }, { type, topics, ref? }, or { type, sessionId, lastSeenSeqs }. The server emits {"type":"welcome","sessionId":"..."} on open, {"type":"resumed"} after a resume frame, and {"type":"subscribed",...} / {"type":"subscribe-denied",...} per topic when the client supplied a ref.
To avoid JSON-parsing every incoming message, the handler uses a byte-prefix discriminator: control messages start with {"type" (byte 3 is y), while user envelopes start with {"topic" (byte 3 is o). A single byte comparison skips JSON.parse entirely for user messages. Messages over 8 KB are also skipped (generous ceiling for subscribe-batch with many topics, well above any realistic control message).
Topics submitted by clients are validated before being accepted:
" and \. Control bytes, line separators (U+2028/U+2029), bidirectional overrides (U+202E), the byte-order mark, and other non-ASCII runes are rejected at the wire boundary so log dashboards and admin UIs see a clean, greppable topic name. Apps that legitimately accept non-ASCII topic names from clients can opt in via websocket.allowNonAsciiTopics: true (always-illegal " and \ remain rejected).subscribe-batch accepts at most 256 topics per message (the client only sends what it was subscribed to before a reconnect)Topics prefixed with __ are reserved for framework-internal channels (presence uses __presence:*, replay uses __replay:*, plus __signal:*, __group:*, __rpc, etc.). Wire-level subscribes to __-prefixed topics are rejected with INVALID_TOPIC, so a client cannot intercept signals routed to other users or plugin broadcasts. Server-side platform.subscribe(ws, '__signal:userId') (the legitimate pattern that enableSignals uses) still works because the block is on the wire layer only. Advanced apps that intentionally route public topics through the __ prefix can opt out via websocket.allowSystemTopicSubscribe: true.
If your handler is somewhere other than src/hooks.ws.js:
adapter({
websocket: {
handler: './src/lib/server/websocket.js'
}
})
The upgrade function receives an UpgradeContext:
{
headers: { 'cookie': '...', 'host': 'localhost:3000', ... }, // all lowercase
cookies: { session_id: 'abc123', theme: 'dark' }, // parsed from Cookie header
url: '/ws?token=abc', // request path + query string
remoteAddress: '127.0.0.1' // client IP
}
The subscribe function receives (ws, topic) and can return false to deny a client's subscription request. Omit it to allow all subscriptions.
The ws object in open, message, close, and drain is a uWebSockets.js WebSocket. Key methods:
ws.getUserData() - returns whatever upgrade returnedws.subscribe(topic) - subscribe to a topic for app.publish()ws.unsubscribe(topic) - unsubscribe from a topicws.send(data) - send a message to this connectionws.close() - close the connectionWebSocket authentication uses the exact same cookies as your SvelteKit app. When the browser opens a WebSocket connection, it sends all cookies for the domain - including session cookies set by SvelteKit's cookies.set(). No tokens, no query parameters, no extra client-side code.
Here's the full flow from login to authenticated WebSocket:
src/routes/login/+page.server.js
import { authenticate, createSession } from '$lib/server/auth.js';
export const actions = {
default: async ({ request, cookies }) => {
const form = await request.formData();
const email = form.get('email');
const password = form.get('password');
const user = await authenticate(email, password);
if (!user) return { error: 'Invalid credentials' };
const sessionId = await createSession(user.id);
// This cookie is automatically sent on WebSocket upgrade requests
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: true,
maxAge: 60 * 60 * 24 * 7 // 1 week
});
return { success: true };
}
};
src/hooks.ws.js
import { getSession } from '$lib/server/auth.js';
export async function upgrade({ cookies }) {
// Same cookie that SvelteKit set during login
const sessionId = cookies.session;
if (!sessionId) return false; // -> 401, connection rejected
const user = await getSession(sessionId);
if (!user) return false; // -> 401, expired or invalid session
// Attach user data to the socket - available via ws.getUserData()
// To refresh the session cookie on connect, use the `authenticate` hook
// (see "Refreshing session cookies on WebSocket connect" below).
// `upgradeResponse()` with custom non-cookie headers is also supported:
// return upgradeResponse({ userId: user.id }, { 'x-session-version': '2' });
return { userId: user.id, name: user.name, role: user.role };
}
export function open(ws, { platform }) {
const { userId, role } = ws.getUserData();
console.log(`${userId} connected (${role})`);
// Subscribe to user-specific and role-based topics
ws.subscribe(`user:${userId}`);
if (role === 'admin') ws.subscribe('admin');
}
export function close(ws, { platform }) {
const { userId } = ws.getUserData();
console.log(`${userId} disconnected`);
}
src/routes/dashboard/+page.svelte
<script>
import { on, status } from 'svelte-adapter-uws/client';
// The browser sends cookies automatically on the upgrade request.
// If the session is invalid, the connection is rejected and
// auto-reconnect will retry (useful if the user logs in later).
const notifications = on('notifications');
const userMessages = on('user-messages');
</script>
{#if $status === 'open'}
<span>Authenticated & connected</span>
{:else if $status === 'connecting'}
<span>Connecting...</span>
{:else}
<span>Disconnected (not logged in?)</span>
{/if}
src/routes/api/notify/+server.js
import { json } from '@sveltejs/kit';
export async function POST({ request, platform }) {
const { userId, message } = await request.json();
// Only that user receives this (they subscribed in open())
platform.publish(`user:${userId}`, 'notification', { message });
return json({ sent: true });
}
The WebSocket upgrade is an HTTP request. The browser treats it like any other request to your domain - it includes all cookies, follows the same-origin policy, and respects httpOnly/secure/sameSite flags. There's no difference between how cookies reach a +page.server.js load function and how they reach the upgrade handler.
| What | Where | Same cookies? |
|---|---|---|
| Page load | +page.server.js load() |
Yes |
| Form action | +page.server.js actions |
Yes |
| API route | +server.js |
Yes |
| Server hook | hooks.server.js handle() |
Yes |
| WebSocket upgrade | hooks.ws.js upgrade() |
Yes |
For short-lived sessions you often want to rotate the session cookie every time a client connects. The obvious approach - attaching Set-Cookie to the 101 Switching Protocols response via upgradeResponse() - is RFC-compliant but is silently rejected by Cloudflare Tunnel, Cloudflare's proxy, and some other strict edge proxies. The symptom is that the WebSocket open handler fires server-side, then the connection closes with code 1006 (Received TCP FIN before WebSocket close frame) before any frames are exchanged. The adapter emits a build-time warning when it detects this pattern.
The adapter ships a first-class solution: the optional authenticate hook runs as a normal HTTP POST before the WebSocket upgrade. Set-Cookie rides on a standard 2xx response, which every proxy handles correctly; the browser then attaches the refreshed cookie to the upgrade request that follows.
Step 1: add an authenticate export to hooks.ws.js
// src/hooks.ws.js
import { getSession, renewSession } from '$lib/server/auth.js';
// Runs as POST /__ws/auth, before the WebSocket upgrade.
// cookies.set() becomes Set-Cookie on a standard 204 response.
export async function authenticate({ cookies }) {
const session = await getSession(cookies.get('session'));
if (!session) return false; // -> 401, client does not open the WebSocket
const renewed = await renewSession(session);
cookies.set('session', renewed.token, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
});
}
// Your existing upgrade() hook stays unchanged - it reads the now-fresh cookie.
export async function upgrade({ cookies }) {
const session = await getSession(cookies.session);
if (!session) return false;
return { userId: session.userId, role: session.role };
}
The authenticate event exposes the SvelteKit event shape you already know: { request, headers, cookies, url, remoteAddress, getClientAddress, platform }. Return values:
undefined / nothing - success, responds 204 No Content with any Set-Cookie headers from cookies.set() (recommended).false - responds 401 Unauthorized. The client does not open the WebSocket.Response - used as-is; any cookies.set() calls are merged in.Step 2: opt in from the client
import { connect } from 'svelte-adapter-uws/client';
// Hit /__ws/auth before every WebSocket connect (including reconnects)
connect({ auth: true });
// Or point at a custom path (e.g. behind a Cloudflare Access rule)
connect({ auth: '/api/ws-auth' });
With auth: true the client stores runs fetch('/__ws/auth', { method: 'POST', credentials: 'include' }) before every new WebSocket(...) call, including after automatic reconnects. Concurrent connect attempts share a single in-flight preflight. A 4xx response is treated as terminal (the user is not authenticated); 5xx and network errors fall back to the normal reconnect backoff.
Configuration
/__ws/auth. Override with adapter({ websocket: { authPath: '/api/ws-auth' } }).authenticate is exported from hooks.ws - no runtime cost when unused.x-requested-with: XMLHttpRequest, Sec-Fetch-Site: same-origin, or an Origin matching allowedOrigins (CSRF defense). The adapter client always stamps x-requested-with. Native (non-browser) clients that need to reach this endpoint without those headers can opt out via websocket.authPathRequireOrigin: false. See Security configuration.Why not put Set-Cookie on the 101?
Cloudflare's HTTP/2 WebSocket bridging rewrites 101 responses, and Set-Cookie on the 101 trips the edge into tearing the connection down. This is undocumented Cloudflare behavior, but reproducible on every tunnel and proxy connector. The authenticate hook sidesteps it entirely by using a standard HTTP response.
event.platform)Available in server hooks, load functions, form actions, API routes, and WebSocket hooks (hooks.ws).
platform.publish(topic, event, data, options?)Send a message to all WebSocket clients subscribed to a topic.
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 (platform.publish(\user:${id}`, ...)`). The validation is a single-pass char scan and adds no measurable overhead.
In cluster mode, the message is automatically relayed to all other workers. Pass { relay: false } to skip the relay when the message originates from an external pub/sub source (Redis, Postgres LISTEN/NOTIFY, etc.) that already delivers to every process:
// Redis subscriber running on every worker - relay would cause duplicates
sub.on('message', (channel, payload) => {
platform.publish(channel, 'update', JSON.parse(payload), { relay: false });
});
Every published frame is also stamped with a monotonic per-topic seq field in the envelope (first publish to a topic is seq: 1, then 2, 3, ...). Reconnecting clients can use this to detect dropped frames and resume from where they left off. Pass { seq: false } to skip stamping for ephemeral or high-cardinality topics where the counter map would grow unbounded:
// Skip seq for per-user cursor topics: counter map would grow with users
platform.publish(`cursor:${userId}`, 'move', pos, { seq: false });
// src/routes/todos/+page.server.js
export const actions = {
create: async ({ request, platform }) => {
const formData = await request.formData();
const todo = await db.createTodo(formData.get('text'));
// Every client subscribed to 'todos' receives this
platform.publish('todos', 'created', todo);
return { success: true };
}
};
platform.send(ws, topic, event, data)Send a message to a single WebSocket connection. Wraps in the same { topic, event, data } envelope as publish().
This is useful when you store WebSocket references (e.g. in a Map) and need to message specific connections from SvelteKit handlers:
// src/hooks.ws.js - store connections by user ID
const userSockets = new Map();
export function open(ws, { platform }) {
const { userId } = ws.getUserData();
userSockets.set(userId, ws);
}
export function close(ws, { platform }) {
const { userId } = ws.getUserData();
userSockets.delete(userId);
}
// Export the map so SvelteKit handlers can access it
export { userSockets };
// src/routes/api/dm/+server.js - send to a specific user
import { userSockets } from '../../hooks.ws.js';
export async function POST({ request, platform }) {
const { targetUserId, message } = await request.json();
const ws = userSockets.get(targetUserId);
if (ws) {
platform.send(ws, 'dm', 'new-message', { message });
}
return new Response('OK');
}
You can also reply directly from inside hooks.ws.js using platform.send() or ws.send() with the envelope format:
// src/hooks.ws.js
export function message(ws, { data, platform }) {
const msg = JSON.parse(Buffer.from(data).toString());
// Using platform.send (recommended):
platform.send(ws, 'echo', 'reply', { got: msg });
// Or using ws.send with manual envelope:
ws.send(JSON.stringify({ topic: 'echo', event: 'reply', data: { got: msg } }));
}
platform.sendCoalesced(ws, { key, topic, event, data })Send a message to a single connection with coalesce-by-key semantics. Each (connection, key) pair holds at most one pending message; if a newer call for the same key arrives before the previous frame drains to the wire, the older value is replaced in place.
Use this for latest-value streams where intermediate values are noise - price ticks, cursor positions, presence state, typing indicators, scroll position. Under load, this is the difference between the client lagging by a thousand stale frames and the client always seeing the most recent value.
If you want a backpressured subscriber to keep eventually receiving the latest value (the queue-and-drain shape), sendCoalesced is the right primitive. If you want backpressured subscribers skipped entirely so the wire stays current for everyone else, use platform.publish / platform.send instead - those drop on backpressure (see the "Volatile / fire-and-forget delivery" section below). sendCoalesced is explicitly drop-the-middle, keep-the-latest; publish / send are explicitly drop-the-laggard, keep-everyone-else-current.
// src/hooks.ws.js - cursor positions during a collaborative edit
export function message(ws, { data, platform }) {
const msg = JSON.parse(Buffer.from(data).toString());
if (msg.event === 'cursor') {
const { docId, userId } = ws.getUserData();
// Coalesce per (connection, user) - one pending cursor frame per peer.
// High-frequency mousemove updates collapse cleanly under backpressure.
for (const peer of getPeersOf(docId)) {
platform.sendCoalesced(peer, {
key: 'cursor:' + userId,
topic: 'doc:' + docId,
event: 'cursor',
data: { userId, x: msg.data.x, y: msg.data.y }
});
}
}
}
Three properties worth knowing:
set on an existing key replaces the value but keeps the original slot, so coalescing one key never reorders the rest of the queue.data is held as-is in the per-connection buffer and only JSON.stringify'd at flush time. A stream that overwrites the same key 1000 times before a single drain pays one serialization, not 1000.maxBackpressure is hit, pumping stops and resumes on the next uWS drain event automatically. No manual flow control.platform.sendTo(filter, topic, event, data)Send a message to all connections whose userData matches a filter function. Returns the number of connections the message was sent to.
This is simpler than manually maintaining a Map of connections - no hooks.ws.js needed:
// src/routes/api/dm/+server.js - send to a specific user
export async function POST({ request, platform }) {
const { targetUserId, message } = await request.json();
const count = platform.sendTo(
(userData) => userData.userId === targetUserId,
'dm', 'new-message', { message }
);
return new Response(count > 0 ? 'Sent' : 'User offline');
}
// Send to all admins
platform.sendTo(
(userData) => userData.role === 'admin',
'alerts', 'warning', { message: 'Server load high' }
);
Performance:
sendToiterates every open connection and runs your filter function against each one. It's fine for low-frequency operations like sending a DM or notifying admins, but don't use it in a hot loop. If you're broadcasting to a known group of users, subscribe them to a shared topic and useplatform.publish()instead - topic-based pub/sub is handled natively by uWS in C++ and doesn't touch the JS event loop.
platform.connectionsNumber of active WebSocket connections:
// src/routes/api/stats/+server.js
import { json } from '@sveltejs/kit';
export async function GET({ platform }) {
return json({ online: platform.connections });
}
platform.subscribers(topic)Number of clients subscribed to a specific topic:
export async function GET({ platform, params }) {
return json({
viewers: platform.subscribers(`page:${params.id}`)
});
}
platform.assertionsPer-category counter of framework invariant violations. The adapter ships internal hard-asserts at ~30 invariant sites (envelope build, WebSocket lifecycle, subscription bookkeeping, cross-worker IPC payloads, server-initiated request entry shape, sendCoalesced state). When one fires, the counter for that category increments and a structured [adapter-uws/assert] line is logged.
Most apps will never see a non-empty entry here. A non-zero counter indicates a regression in the framework or a third-party plugin and should be reported as a GitHub issue with the category string and accompanying log context.
export async function GET({ platform }) {
// Surface the counters in your /healthz or ops dashboard
const assertions = {};
for (const [category, count] of platform.assertions) {
assertions[category] = count;
}
return json({ healthy: Object.keys(assertions).length === 0, assertions });
}
The returned Map is the live module-level instance - read-only, do not mutate. In test mode (process.env.VITEST set, or NODE_ENV === 'test') the assert helper additionally throws so test runners surface the failure; in production it logs and counts but does not throw, so a violation inside a uWS callback frame cannot crash the worker.
platform.closedWsAbortsPer-worker count of best-effort uWS operations that aborted because the underlying WebSocket had already closed. Bumped every time platform.subscribe, platform.unsubscribe, platform.send, platform.sendCoalesced, platform.sendTo, or platform.request is called on a ws whose native handle has been freed - typically because the caller await-ed something (auth, loader, subscribe hook) and the client closed during the wait.
These methods are closed-WS safe by contract: they swallow uWS's Invalid access of closed uWS.WebSocket exception, return a success-shaped no-op sentinel (null for subscribe, false for unsubscribe, 2 for send, etc.), and bump this counter. Callers can fire-and-forget without a per-site try/catch.
export async function GET({ platform }) {
return json({ closedWsAborts: platform.closedWsAborts });
}
A non-zero value is normal under client churn (tab close, network blips, mass reconnect waves). A rapidly-growing value under steady load indicates either pathological client behaviour or that the server's async setup path is too long for its connect rate. In clustered mode, sum across workers for cluster-wide visibility.
Monotonic, per-worker, reset only on process restart.
platform.pressure and platform.onPressure(cb)Worker-local backpressure signal. The adapter samples once per second (configurable) and reports the most urgent active stress as a single reason enum, so user code can degrade with intent instead of generic panic.
platform.pressure
// {
// active: false,
// subscriberRatio: 12.4, // total subscriptions / connections, on this worker
// publishRate: 240, // platform.publish() calls/sec, last sample
// memoryMB: 128, // process.memoryUsage().rss in MB
// reason: 'NONE' // 'NONE' | 'PUBLISH_RATE' | 'SUBSCRIBERS' | 'MEMORY'
// }
Reading platform.pressure is a property access - safe in hot paths, no I/O. Use it for synchronous shed decisions in request handlers:
// src/routes/api/heavy-write/+server.js
export async function POST({ platform, request }) {
if (platform.pressure.reason === 'MEMORY') {
return new Response('Try again shortly', { status: 503 });
}
// ... normal write path
}
platform.onPressure(cb) fires only on transitions (when reason changes between samples), not on every tick. Returns an unsubscribe function:
// src/hooks.ws.js - notify the connected client when pressure state changes
export function open(ws, { platform }) {
const off = platform.onPressure(({ reason, active }) => {
platform.send(ws, '__pressure', reason, { active });
});
ws.getUserData().__offPressure = off;
}
export function close(ws) {
ws.getUserData().__offPressure?.();
}
Reason precedence is fixed: MEMORY > PUBLISH_RATE > SUBSCRIBERS. A worker under multiple stresses reports the most urgent one. Memory wins because the worker is approaching OOM and nothing else matters; publish rate is next because CPU saturation cascades fastest; subscriber ratio is last because heavy fan-out degrades gracefully.
Thresholds are configurable per-deployment. Defaults are conservative - a healthy small app should never trip them in steady state. Override via WebSocketOptions.pressure:
// svelte.config.js
import adapter from 'svelte-adapter-uws';
export default {
kit: {
adapter: adapter({
websocket: {
pressure: {
memoryHeapUsedRatio: 0.9, // default 0.85
publishRatePerSec: 50000, // default 10000 (aggregate)
subscriberRatio: false, // disable this signal
sampleIntervalMs: 500, // default 1000; clamped to >=100
topicPublishRatePerSec: 10000, // default 5000 (per topic)
topicPublishBytesPerSec: 5_000_000 // default 10485760 (10 MB/s per topic)
}
}
})
}
};
Set any individual threshold to false to disable that signal. sampleIntervalMs is clamped to a minimum of 100 ms.
Clustering:
platform.pressureis per-worker. Each worker samples its own counters and reports its own snapshot. There is no aggregate "cluster pressure" - a hot worker should shed its own load without waiting for the rest of the cluster.
Beyond the aggregate publishRatePerSec signal, the sampler also tracks per-topic publish rates and surfaces the top 5 each tick:
platform.pressure.topPublishers
// [
// { topic: 'cursor:room-42', messagesPerSec: 8500, bytesPerSec: 1234567 },
// { topic: 'audit:org-1', messagesPerSec: 1200, bytesPerSec: 234567 },
// ...
// ]
When a topic crosses topicPublishRatePerSec or topicPublishBytesPerSec in a sample window, the adapter flags it as a runaway publisher. By default this prints a throttled console.warn (one per topic per minute). For programmatic handling, register platform.onPublishRate(cb) - doing so suppresses the default warning so you own the surface:
platform.onPublishRate((events) => {
for (const e of events) {
metrics.record('runaway_publisher', {
topic: e.topic,
msgRate: e.messagesPerSec,
byteRate: e.bytesPerSec
});
}
});
The bookkeeping is cheap: the per-topic counter mutates two integer fields in place per platform.publish() call (one entry allocated the first time a topic is published to, then zero allocations forever after). Set topicPublishRatePerSec: false and topicPublishBytesPerSec: false to disable per-topic tracking entirely if you do not want it.
platform.topic(name) - scoped helperReduces repetition when publishing multiple events to the same topic:
// src/routes/todos/+page.server.js
export const actions = {
create: async ({ request, platform }) => {
const todos = platform.topic('todos');
const todo = await db.create(await request.formData());
todos.created(todo); // shorthand for platform.publish('todos', 'created', todo)
},
update: async ({ request, platform }) => {
const todos = platform.topic('todos');
const todo = await db.update(await request.formData());
todos.updated(todo);
},
delete: async ({ request, platform }) => {
const todos = platform.topic('todos');
const id = (await request.formData()).get('id');
await db.delete(id);
todos.deleted({ id });
}
};
The topic helper also has counter methods:
const online = platform.topic('online-users');
online.set(42); // -> { event: 'set', data: 42 }
online.increment(); // -> { event: 'increment', data: 1 }
online.increment(5); // -> { event: 'increment', data: 5 }
online.decrement(); // -> { event: 'decrement', data: 1 }
platform.batch(messages)Publish multiple messages in a single call. Useful when an action updates several topics at once:
platform.batch([
{ topic: 'todos', event: 'created', data: todo },
{ topic: `user:${userId}`, event: 'activity', data: { action: 'create' } },
{ topic: 'stats', event: 'increment', data: { key: 'todos_created' } }
]);
Each entry is published with platform.publish(). Cross-worker relay is batched automatically, so this is more efficient than three separate publish() calls from a relay overhead perspective.
platform.request(ws, event, data, options?)Send a request to one connection and await its reply. Use this for server-driven confirmations, capability challenges, or any flow where the server needs an answer from a specific client.
// In a hook on the server
const reply = await platform.request(ws, 'confirm-action', { op: 'delete' }, {
timeoutMs: 5000
});
if (reply.confirmed) {
await actuallyDelete();
}
The framework picks a fresh ref, sends {type:'request', ref, event, data}, and the returned Promise resolves with whatever the client's onRequest handler returned. Rejects with Error('request timed out') after timeoutMs (default 5000) and with Error('connection closed') if the WebSocket closes before a reply arrives.
The client side opts in by registering a single handler:
import { onRequest } from 'svelte-adapter-uws/client';
onRequest(async (event, data) => {
if (event === 'confirm-action') {
return { confirmed: confirm(`Are you sure? (${data.op})`) };
}
throw new Error('unknown event: ' + event);
});
Throw or reject from the handler to send an error reply; the server's awaiting Promise rejects with the same message. With no handler installed, request frames are dropped silently and the server times out.
platform.publishBatched(messages)Publish a list of {topic, event, data} events as a single {type:'batch', events:[...]} WebSocket frame per affected subscriber, instead of one frame per event. Each subscriber receives only the events whose topics are in their subscription set, in submitted order. Subscribers with no overlap with the batch's topics receive nothing.
// Form action publishing several related events:
export const actions = {
closeBook: async ({ platform }) => {
const { items, audit } = await db.closeBook();
platform.publishBatched([
...items.map(i => ({ topic: 'org:42:items', event: 'updated', data: i })),
{ topic: 'org:42:audit', event: 'closed', data: audit }
]);
}
};
The frame the client receives:
{ "type": "batch", "events": [
{ "topic": "org:42:items", "event": "updated", "data": ..., "seq": 17 },
{ "topic": "org:42:items", "event": "updated", "data": ..., "seq": 18 },
{ "topic": "org:42:audit", "event": "closed", "data": ..., "seq": 4 }
] }
The bundled svelte-adapter-uws/client decodes the batch frame and dispatches each contained event through the same per-topic store ladder a single-event frame would take - indistinguishable from N individual frames except for the latency drop and the lower onmessage bill.
When the win shows up. Wire-level batching has two characteristic shapes that pay off:
publish() loop (When the win does not apply. Mixed subscriber views (some subs see only a subset of the batch's topics) and small disjoint batches (e.g. 3 events to 3 topics with disjoint subscriber sets) cannot share one frame - the C++ TopicTree fanout used by publish() is faster than building per-subscriber payloads in JS for those shapes. publishBatched detects these cases and falls back to a per-event publish() loop, so calling it is at least as fast as the publish loop the user would have written by hand. Verified on a third bench profile (3 events x 50 subs disjoint topics): delta 0.3% (within noise).
Capability handshake. The client opts in by sending {type:'hello', caps:['batch']} after the WebSocket opens. The bundled client does this automatically. When the server detects that any interested subscriber has not advertised the 'batch' capability, the call falls back to the publish loop so old clients receive plain envelopes they can decode. Mixing old and new clients in the same call is safe; old clients simply do not benefit from the batched optimization.
Cross-worker relay. A single publish-batched IPC frame carries the full event list to other workers in cluster mode. Each receiving worker re-runs the fast-path detection against ITS local subscriber set and dispatches via either a single batch envelope (fast path) or per-event publishes (slow path). Wire batching is preserved cluster-wide instead of degrading to per-event relays at the worker boundary. Pass {relay: false} per-event to skip the relay (use when the messages came from an external pub/sub source already fanning out to every worker).
Coalesce by key. Each event takes an optional coalesceKey?: string field. Events that share a key collapse so only the latest value survives, with the surviving entry kept at the latest occurrence's position. Events without a key pass through unchanged. Use this for high-frequency batches where intermediate values are noise - a single call carrying 100 cursor positions for the same user delivers only the latest.
platform.publishBatched(positions.map(p => ({
topic: 'cursors',
event: 'move',
data: p,
coalesceKey: 'cursor:' + p.userId // latest position per user wins
})));
Frame-size budget. A batched frame larger than 256 KB triggers a throttled console.warn; uWS per-message-deflate may kick in at large sizes and surprise CPU budgets. Chunk into multiple publishBatched calls when the warning fires.
Order and seq. Within one batched frame, events appear in call order (after any coalesceKey collapsing). Each event is independently stamped with a per-topic monotonic seq, identical to publish(); pass {seq: false} per-event to skip stamping for that one event. The streaming sendCoalesced API (per-key replacement on a single connection) is independent of publishBatched's batch-level coalesceKey; mixing the two on the same subscriber is supported but produces separate frames.
Two distinct contracts - pick one. The existing platform.batch(messages) is NOT wire-level batching - it is a for loop calling publish() once per message, so N submitted messages still produce N WebSocket frames per subscribed connection. The cross-worker relay coalesces per microtask, but the client still pays N onmessage dispatches. Use batch() when you want per-message return values; use publishBatched() when you want one-frame-per-subscriber wire batching.
platform.requestIdA correlation id you can thread through structured logs to follow a single request across server hooks, load functions, and downstream services.
For HTTP requests, a fresh UUID is generated per request. For WebSocket connections the id is stamped once at upgrade and reused for every hook on that connection (open, subscribe, message, drain, close). In both cases an inbound X-Request-ID header overrides the generated value when present, so callers, gateways, and tracing collectors can supply their own id and have it follow the request through your code.
// src/routes/api/orders/+server.js
export async function POST({ platform, request }) {
const log = logger.child({ requestId: platform.requestId });
log.info('order request received');
const order = await db.create(await request.json());
platform.publish('orders', 'created', order);
log.info({ orderId: order.id }, 'order published');
return json({ ok: true, requestId: platform.requestId });
}
// hooks.ws.js - the same id flows through every WS hook on a connection
export function open(ws, { platform }) {
logger.info({ requestId: platform.requestId }, 'ws open');
}
export function close(ws, { platform, code }) {
logger.info({ requestId: platform.requestId, code }, 'ws close');
}
The header value is sanitized before being used: only printable ASCII (no whitespace, no control chars) up to 128 chars is honoured. Anything else is ignored and a fresh UUID is generated instead, so the id is always safe to interpolate into log lines.
The adapter never writes X-Request-ID on the response automatically - emitting it back is an app-layer choice (usually for outbound observability). Set it explicitly if you want callers to see it:
return new Response(body, {
headers: { 'x-request-id': platform.requestId }
});
Dev-mode note: in
vite dev, the dev server generates a fresh UUID per request but does not honourX-Request-IDfor HTTP traffic (SvelteKit'semulate.platform()runs without access to request headers). Production reads the header. Dev-mode WebSocket connections honour the header normally.
platform.publish, platform.send, and platform.publishBatched are all volatile under backpressure. When a specific subscriber's outbound buffer is over maxBackpressure (default 1 MB, configurable in websocket.maxBackpressure), uWS skips that subscriber for that frame while continuing to deliver to every non-backpressured subscriber. The skip is silent, per-subscriber, and does not queue for retry. There is no separate volatile: true flag because the volatile semantic is the default.
This is the right behavior for transient state where stale values are worse than dropped ones - cursor positions, typing indicators, presence pings, telemetry pulses, draft auto-saves. Slow / disconnected / backgrounded subscribers fall behind silently while everyone else stays current.
// Cursor broadcast: every reader gets the latest position they can keep up
// with; lagging readers silently lose intermediate values, no queue grows.
platform.publish(`doc:${docId}:cursors`, 'move', { userId, x, y }, { seq: false });
Pair with { seq: false } to opt out of seq stamping for these high-cardinality, replay-uninteresting topics. The seq counter map is per-topic and grows with cardinality, so opting out keeps memory bounded for unbounded-cardinality topic spaces like cursor:${userId} or presence:${sessionId}.
For the drop-the-middle, keep-the-latest shape on a single connection (the value still arrives, just collapsed across intermediate frames), use platform.sendCoalesced(ws, ...) instead. That path queues per (connection, key) and drains on the next uWS drain event rather than dropping. Quick comparison:
| Primitive | Behavior under backpressure |
|---|---|
platform.publish / send / publishBatched |
Skip the backpressured subscriber, deliver to others. No retry. |
platform.sendCoalesced(ws, { key, ... }) |
Queue per (ws, key), latest value wins, drain on next onWritable. |
To tune how aggressively backpressured subscribers get skipped, lower maxBackpressure in websocket options (the smaller the buffer, the sooner uWS starts skipping). The 1 MB default favors keeping the connection alive over shedding load; drop to 64 KB or 256 KB if your workload prefers shedding faster.
For visibility into whether subscribers are actually being skipped at scale, watch platform.pressure.publishRate and platform.pressure.topPublishers - a topic publishing far above its consumer rate is the canonical signature of a backpressure-shedding workload.
Import from svelte-adapter-uws/client. Everything auto-connects - you don't need to call connect() first.
on(topic) - subscribe to a topicThe 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 eventFilters 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 stateLike 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 subscriptionSubscribes 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 listSubscribes 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 objectLike 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 expiryBoth 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 bufferKeeps 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 counterHandles 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
subscribemessage hasn't been processed yet whenopenfires. See Seeding initial state for the fix.
once(topic, event?, options?) - wait for one eventReturns 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 });
status - connection statusReadable store with the current connection state. Five states drive distinct UI affordances:
'connecting' - establishing a connection (initial attempt or retry)'open' - connected, live data is flowing'suspended' - WS is technically open but the tab is in the background; server may close idle backgrounded sockets, so live data is best-effort'disconnected' - lost connection, will retry automatically'failed' - terminal: auth denied, max retries exhausted, or close() called<script>
import { status } from 'svelte-adapter-uws/client';
</script>
{#if $status === 'open'}
<span class="badge green">Live</span>
{:else if $status === 'suspended'}
<span class="badge muted">Paused (tab in background)</span>
{:else if $status === 'connecting'}
<span class="badge yellow">Connecting...</span>
{:else if $status === 'disconnected'}
<span class="badge orange">Reconnecting...</span>
{:else}
<span class="badge red">Connection failed</span>
{/if}
The 'suspended' overlay flips back to 'open' automatically when the tab returns to the foreground (assuming the WebSocket survived the hide period; if it did not, the state machine drives 'connecting' -> 'open' via the normal reconnect path).
failure - cause of the most recent disconnectSibling Readable to status. Use status to drive UI state; use failure to drive what message you show. Stays at null while connected, set when the connection drops, cleared on the next successful 'open'.
The value is a discriminated union by kind:
type Failure =
| { kind: 'ws-close'; class: 'TERMINAL' | 'EXHAUSTED' | 'THROTTLE' | 'RETRY'; code: number; reason: string }
| { kind: 'auth-preflight'; class: 'AUTH'; status: number; reason: string };
Five class values let consumers render targeted UI without inspecting raw close codes:
'TERMINAL' - server permanently rejected the client (close codes 1008 / 4401 / 4403). The retry loop is stopped; the user must re-authenticate or refresh.'EXHAUSTED' - reconnect attempts exceeded maxReconnectAttempts. The network never recovered; surface a manual-retry button.'THROTTLE' - server signalled rate-limiting (close code 4429). Reconnect is still scheduled, jumped ahead in the backoff curve.'RETRY' - normal transient drop (1006 abnormal closure, network blip, server restart). Reconnect is in progress; usually paired with the 'disconnected' status.'AUTH' - the auth preflight ({ auth: true }) failed before the WebSocket was opened. 4xx is terminal; 5xx and network errors retry. The HTTP status code is in status, not code.failure === null while status === 'failed' is the deliberately-ended state - the user called close(), not a transport-level failure.
<script>
import { status, failure } from 'svelte-adapter-uws/client';
</script>
{#if $failure?.class === 'TERMINAL'}
<p class="error">Session expired. <a href="/login">Sign in again</a></p>
{:else if $failure?.class === 'EXHAUSTED'}
<p class="error">Connection lost. <button onclick={() => location.reload()}>Reload</button></p>
{:else if $failure?.class === 'THROTTLE'}
<p class="warn">Server is busy. Retrying shortly...</p>
{:else if $failure?.class === 'AUTH'}
<p class="error">Could not authenticate (HTTP {$failure.status}). <a href="/login">Sign in</a></p>
{:else if $status === 'disconnected'}
<span>Reconnecting...</span>
{/if}
ready() - wait for connectionReturns 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 and no explicit url), ready() resolves immediately and is a no-op. In native app environments where window doesn't exist but you passed a url to connect(), ready() correctly waits for the connection to open.
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 APIMost 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({
url: 'wss://my-app.com/ws', // full URL for cross-origin / native app usage (overrides path)
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();
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.
Microtask-batched initial subscribes: multiple subscribe(topic) calls landing in the same microtask coalesce into one subscribe-batch wire frame. A page that mounts many topic stores in a tight loop (a multi-stream dashboard, a svelte-realtime page initializing 5 stream RPCs) triggers the server's subscribeBatch hook ONCE instead of the per-topic subscribe hook N times. Single-topic case stays as a plain subscribe frame for the minimal-change wire shape. Same chunking limits as the reconnect path. Topics are still added to the local subscription set synchronously, so a disconnect between the call and the microtask flush loses nothing - the reopen path picks them up. Test-code note: code asserting on the exact wire shape of two same-microtask subscribes seeing two subscribe frames now sees one subscribe-batch frame; use .find(m => m.type === 'subscribe-batch' && m.topics.includes(...)) instead.
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.
By default, the client derives the WebSocket URL from window.location. If your client runs on a different origin - a mobile app (Svelte Native, React Native), a standalone Node.js script, or any context where the backend lives elsewhere - pass a url to connect to it directly:
import { connect, on } from 'svelte-adapter-uws/client';
connect({ url: 'wss://my-app.com/ws' });
const todos = on('todos');
When url is set, path is ignored and the window check is bypassed, so the client works in environments without a browser DOM. All other features (reconnect, backoff, batch resubscription, topic stores) work the same way.
Note: Your server's
allowedOriginsconfig must include the origin your client connects from (or'*'during development). See the origin validation section.
When a client connects, there's a window between the WebSocket opening and the client's topic subscriptions being processed. Any platform.publish() calls that happen during open will be missed by the connecting client, because it hasn't subscribed to those topics yet.
This matters most with count(). If your open hook does platform.topic('online').set(total), the connecting client won't see it - the set event is broadcast before the client's subscribe message arrives.
The fix is to use the subscribe hook instead of (or alongside) open 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);
}
<!-- src/routes/+page.svelte -->
<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 to the topic. 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 any topic where new subscribers need to see the current state. For a CRUD list, you could send the full dataset in subscribe:
// src/hooks.ws.js
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}
Opt-in modules that build on top of the adapter's public API. They don't change any core behavior - if you don't import them, they don't exist. Each plugin ships in its own subdirectory under plugins/ with separate server and client entry points.
Every plugin in this directory is an authorization-free primitive. None of them know who the caller is, what roles the caller has, or whether the requested action is allowed - they execute whatever the caller passes. Calling withLock(key, fn), replay.replay(ws, topic, since), or idempotency.handle(key, fn) is no more an authorization check than calling Map.set(key, value) is one.
Your message handler is the gate. Identity is established at connect time by the upgrade() hook and stashed on the socket via ws.getUserData(). Your message() handler reads that identity, decides whether the action is allowed, and only then invokes the plugin:
// hooks.ws.js
import { withLock } from 'svelte-adapter-uws/plugins/lock';
export async function message(ws, { data }) {
const { topic, action, payload } = JSON.parse(Buffer.from(data).toString());
const { userId, role } = ws.getUserData() ?? {};
// 1. Authentication: did upgrade() reject? If not, ws.getUserData() is non-empty.
if (!userId) return;
// 2. Authorization: this handler decides. The plugin does not.
if (action === 'reset-counter' && role !== 'admin') return;
// 3. Only now is the plugin invoked. withLock has no idea who the caller is.
await withLock(`counter:${topic}`, async () => {
// ... critical section
});
}
Higher-level frameworks built on this adapter (e.g. svelte-realtime) wrap this pattern: ctx.user is the same identity object the upgrade() hook returned, and the framework's _guard / live.public() / // realtime-allow-public machinery is the authorization layer at the RPC seam. The plugins below still do not gate anything themselves; the framework's auth lives outside them.
The same pattern applies to every plugin in this section: read identity, decide, then invoke. A plugin that "looks like an auth gate" by virtue of taking a userId-shaped key (e.g. presence.subscribe(user:${userId})) is just substituting whatever string the caller hands it - if your handler interpolates payload.targetUserId from the wire without checking that the caller owns it, the plugin will happily address a user the caller has no business touching.
Composable message processing pipeline. Chain functions that run on inbound messages before your handler logic. Each middleware receives a context and a next function - call next() to continue, skip it to stop the chain.
// src/lib/server/pipeline.js
import { createMiddleware } from 'svelte-adapter-uws/plugins/middleware';
export const pipeline = createMiddleware(
// logging
async (ctx, next) => {
console.log(`[${ctx.topic}] ${ctx.event}`);
await next();
},
// auth check
async (ctx, next) => {
const userId = ctx.ws.getUserData()?.userId;
if (!userId) return; // stop chain - unauthenticated
ctx.locals.userId = userId;
await next();
},
// data enrichment
async (ctx, next) => {
ctx.data = { ...ctx.data, processedAt: Date.now() };
await next();
}
);
// src/hooks.ws.js
import { pipeline } from '$lib/server/pipeline';
export async function message(ws, { data, platform }) {
const msg = JSON.parse(Buffer.from(data).toString());
const ctx = await pipeline.run(ws, msg, platform);
if (!ctx) return; // chain was stopped (e.g. auth failed)
// ctx.locals.userId is available here
// ctx.data has the enriched data
}
| Method | Description |
|---|---|
pipeline.run(ws, message, platform) |
Execute the chain. Returns context or null if stopped |
pipeline.use(fn) |
Append a middleware at runtime |
The context object:
| Field | Description |
|---|---|
ctx.ws |
The WebSocket connection |
ctx.message |
Original parsed message |
ctx.topic |
Message topic (mutable) |
ctx.event |
Message event (mutable) |
ctx.data |
Message data (mutable) |
ctx.platform |
Platform reference |
ctx.locals |
Scratch space for middleware to share data |
ctx.locals to pass data between middlewares within a single message.next() guard. Calling next() twice in the same middleware is a no-op (the second call does nothing).When you combine SSR with WebSocket live updates, there's a gap between server-side data loading and the moment the client's WebSocket connects. Messages published during that window are lost.
The replay plugin solves this without touching the adapter core. It's opt-in - if you don't import it, it doesn't exist.
Authorization: the replay buffer is identity-blind. It replays whatever messages were captured to whoever asks for them. Your
message()handler (or your topic-subscribe gate) is the place that decides whether the requesting socket is allowed to see this topic's history. See Authorization model.
platform.publish() directly - messages get a sequence number and are stored in a ring bufferload() functiononReplay() connects, requests missed messages, and switches to live mode once caught upCreate a shared replay instance:
// src/lib/server/replay.js
import { createReplay } from 'svelte-adapter-uws/plugins/replay';
export const replay = createReplay({ size: 500 });
Use it when publishing:
// src/routes/chat/+page.server.js
import { replay } from '$lib/server/replay';
export async function load() {
const messages = await db.getRecentMessages();
return { messages, seq: replay.seq('chat') };
}
export const actions = {
send: async ({ request, platform }) => {
const form = await request.formData();
const msg = await db.createMessage(Object.fromEntries(form));
replay.publish(platform, 'chat', 'created', msg);
}
};
Handle replay requests in your WebSocket handler:
// src/hooks.ws.js
import { replay } from '$lib/server/replay';
export function message(ws, { data, platform }) {
const msg = JSON.parse(Buffer.from(data).toString());
if (msg.type === 'replay') {
replay.replay(ws, msg.topic, msg.since, platform, msg.reqId);
return;
}
}
Subscribe on the client with gap-free delivery:
<!-- src/routes/chat/+page.svelte -->
<script>
import { onReplay } from 'svelte-adapter-uws/plugins/replay/client';
let { data } = $props();
const messages = onReplay('chat', { since: data.seq }).scan(
data.messages,
(list, { event, data }) => {
if (event === 'created') return [...list, data];
return list;
}
);
</script>
{#each $messages as msg}
<p>{msg.text}</p>
{/each}
import { createReplay } from 'svelte-adapter-uws/plugins/replay';
const replay = createReplay({
size: 1000, // max messages per topic (default: 1000)
maxTopics: 100 // max tracked topics, LRU evicted (default: 100)
});
replay.publish(platform, topic, event, data) // publish + buffer
replay.seq(topic) // current sequence number
replay.since(topic, seq) // buffered messages after seq
replay.replay(ws, topic, sinceSeq, platform, reqId) // send missed messages to one client
replay.clear() // reset everything
replay.clearTopic(topic) // reset one topic
import { onReplay } from 'svelte-adapter-uws/plugins/replay/client';
// Works exactly like on() but bridges the SSR gap
const store = onReplay('chat', { since: data.seq });
// .scan() works the same as on().scan()
const messages = onReplay('chat', { since: data.seq }).scan([], reducer);
Each onReplay() call generates a unique request ID that is sent with the replay request and matched against the server's responses. This means multiple onReplay('chat', ...) instances on the same page (e.g. two components subscribing to the same topic) each receive only their own replay stream and don't see each other's events. The server must pass msg.reqId to replay.replay() as shown above for this to work.
Buffer overflow: If more than size messages were published before the client connected and the ring buffer wrapped around, the store emits a synthetic { event: 'truncated', data: null } event after the replayed messages. Check for it in your reducer or subscriber to decide whether to reload all data from the server:
const messages = onReplay('chat', { since: data.seq }).scan(data.messages, (list, { event, data }) => {
if (event === 'truncated') return []; // buffer overflow - reload from server
if (event === 'created') return [...list, data];
return list;
});
size messages are published to a topic before a client requests replay, the oldest are gone. Size the buffer for your expected throughput during the SSR-to-connect window (usually well under 100 messages).In-process "have I seen this id before?" cache with fixed-window TTL. The natural use is wrapping a side-effecting handler so client retries after a flaky disconnect don't double-execute - charge a card once, send an email once, deduct inventory once - even when the client legitimately retries the same call.
The TTL is the deduplication window: an id is considered "fresh" for ttl ms after the first claim. Duplicate claims within the window do NOT extend the TTL (semantics match Redis SET NX EX, which is the eventual swap target if you outgrow the in-process variant).
Authorization: dedup keys are whatever the caller passes. If your handler builds the id from a wire field without checking ownership (e.g.
id: payload.clientRequestId), one user can deliberately collide with another user's id and block their next legitimate request. Always derive the dedup key from a trusted identity prefix - e.g.\order:${ws.getUserData().userId}:${payload.clientRequestId}``. See Authorization model.
// src/lib/server/dedup.js
import { createDedup } from 'svelte-adapter-uws/plugins/dedup';
// 5-minute window: a retry within 5 minutes sees the duplicate; after
// the window the same id is treated as a fresh delivery.
export const messages = createDedup({ ttl: 5 * 60 * 1000 });
// src/hooks.ws.js
import { messages } from '$lib/server/dedup';
export function message(ws, { data }) {
const msg = JSON.parse(Buffer.from(data).toString());
if (!messages.claim(msg.id)) return; // duplicate, ignore silently
processMessage(msg); // side effects only run once per id within ttl
}
The pattern composes with idempotency-key headers on form actions and RPCs the same way - the client generates a stable id (UUID v7 or crypto.randomUUID()), persists it locally before submission, and reuses it on retry. The server's first claim() succeeds and runs the side effect; the retry's claim() returns false and the handler exits early.
| Method | Description |
|---|---|
dedup.claim(id) |
Try to claim id as first-sight. Returns true if unseen / expired (and records a fresh window). Returns false if id is currently inside its window. |
dedup.has(id) |
true iff id was claimed and is still within its window. Lazy-prunes expired ids on access. |
dedup.delete(id) |
Forget id explicitly. Returns true if the entry was live before deletion, false otherwise. |
dedup.size() |
Current number of retained ids (may include expired ids not yet pruned). |
dedup.clear() |
Forget all ids. |
| Option | Default | Description |
|---|---|---|
ttl |
required | Deduplication window in milliseconds. Must be positive. |
maxEntries |
10000 |
Soft cap on retained ids. When the map grows past 110% of this cap, expired entries are pruned in a single pass; if still over cap (every entry is inside its window), the oldest insertion-order entries are evicted regardless. |
svelte-adapter-uws-extensions.ttl after the original is treated as a fresh delivery. Choose ttl longer than your worst-case retry latency.Track who's connected to a topic in real time. Handles multi-tab dedup (same user with two tabs open = one presence entry), broadcasts compact join/leave diffs (microtask-batched so multi-event ticks collapse to one frame), and provides a live store on the client.
Authorization: the presence list shows whoever subscribes to the topic - the plugin does not gate topic-subscribe. If a topic should be limited to a subset of users (
team:42-> only team-42 members), enforce that in yoursubscribehandler or via a topic gate. Theselectcallback only chooses which fields ofws.getUserData()to publish; it does not decide whether the caller is allowed on the topic. See Authorization model.
Create a shared presence instance:
// src/lib/server/presence.js
import { createPresence } from 'svelte-adapter-uws/plugins/presence';
export const presence = createPresence({
key: 'id',
select: (userData) => ({ id: userData.id, name: userData.name })
// heartbeat: 30_000 (default) - broadcast every 30s; clients refresh maxAge / re-add aged-out entries
// maxConnections: 1_000_000 (default) - hard cap on tracked connections
// maxTopics: 1_000_000 (default) - hard cap on active topic registry
});
The two cap options bound internal Maps that grow with topic cardinality (chat-${userId} patterns) and connection count. Eviction at cap drops the oldest insertion-order entry. In practice eviction is rare because presence.hooks.close calls leave(ws) automatically on disconnect.
Wire it into your WebSocket hooks:
// src/hooks.ws.js
import { presence } from '$lib/server/presence';
export function upgrade({ cookies }) {
const user = validateSession(cookies.session_id);
if (!user) return false;
return { id: user.id, name: user.name };
}
export const { subscribe, unsubscribe, close } = presence.hooks;
The hooks object handles everything: subscribe calls join() for regular topics and sends the current presence snapshot for __presence:* topics, close calls leave(). If you need custom logic (auth gating, topic filtering), wrap the hook:
export function subscribe(ws, topic, ctx) {
if (topic === 'vip' && !ws.getUserData().isVip) return false;
presence.hooks.subscribe(ws, topic, ctx);
}
export const { unsubscribe, close } = presence.hooks;
Use it on the client:
<!-- src/routes/room/+page.svelte -->
<script>
import { on } from 'svelte-adapter-uws/client';
import { presence } from 'svelte-adapter-uws/plugins/presence/client';
const messages = on('room');
const users = presence('room');
</script>
<aside>
<h3>{$users.length} online</h3>
{#each $users as user (user.id)}
<span>{user.name}</span>
{/each}
</aside>
Use presence.list() in load functions for SSR:
// +page.server.js
import { presence } from '$lib/server/presence';
export async function load() {
return { users: presence.list('room'), online: presence.count('room') };
}
import { createPresence } from 'svelte-adapter-uws/plugins/presence';
const presence = createPresence({
key: 'id', // field for multi-tab dedup (default: 'id')
select: (userData) => userData, // extract public fields (default: recursive denylist)
heartbeat: 30_000 // broadcast every 30s (default: 30000; pass 0 to disable)
});
presence.hooks // ready-made { subscribe, unsubscribe, close } hooks
presence.join(ws, topic, platform) // add user to topic (call from subscribe hook)
presence.leave(ws, platform) // remove from all topics (call from close hook)
presence.sync(ws, topic, platform) // send snapshot without joining (for observers)
presence.list(topic) // current user data array
presence.count(topic) // unique user count
presence.flushDiffs() // drain buffered diff publishes synchronously
presence.clear() // reset everything (stops heartbeat timer)
The plugin emits three frame types on the __presence:{topic} channel:
{event: 'state', data: {[key]: meta}} - full snapshot, sent to a single connection on join or sync.{event: 'diff', data: {joins: {[key]: meta}, leaves: {[key]: meta}}} - changes, broadcast to all subscribers of the topic.{event: 'heartbeat', data: {[key]: meta}} - periodic full-roster refresh, broadcast every heartbeat ms (30 s default). Carries a {userKey: data} map so a client whose entry aged out of its local maxAge sweep can re-add it from the heartbeat alone, without waiting for the next diff.Diffs are buffered in a microtask queue: multiple joins / leaves in the same tick collapse into one diff frame. Within a diff, leaves are applied first then joins, so an update (same key in both) ends with the user present using the new data. If a key cycles join then leave in the same tick, the diff carries only the latest op (leave wins).
The Redis-backed variant in the extensions package emits the same three frame shapes, so the same client bundle works against either backend.
import { presence } from 'svelte-adapter-uws/plugins/presence/client';
const users = presence('room');
// $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]
The client store defaults to a 90 s maxAge sweep: entries that haven't been refreshed by a heartbeat or diff / state inside the window are removed from the local map. With the server's 30 s default heartbeat, still-present users are refreshed three times per window and never flicker; ghost entries left over by silent server-side cleanup (cluster mass-disconnect, ungraceful client close) clear within one sweep window.
For admin / audit views that want unbounded retention ("show every user who ever touched this topic"), opt out with maxAge: 0:
const everyoneEver = presence('room', { maxAge: 0 });
To customize the window, set maxAge and the matching server heartbeat together (rule of thumb: heartbeat is one-third of maxAge or less, so a still-present user gets at least two refreshes per sweep window):
// Server: heartbeat every 60s
const presence = createPresence({ key: 'id', heartbeat: 60_000 });
// Client: entries expire after 180s without a heartbeat refresh
const users = presence('room', { maxAge: 180_000 });
If user "Alice" (key id: '1') has three browser tabs open, presence.join() is called three times with the same key. The plugin ref-counts connections per key: Alice appears once in the list. When she closes two tabs, she stays present. Only when the last tab closes does the plugin broadcast a leave event.
If Alice's data changes between connections (for example she updates her avatar in one session and opens a fresh tab), join() detects the difference and broadcasts an updated event so other clients immediately see the new data. The updated event has the same shape as join: { key, data }.
If no key field is found in the selected data (e.g. no auth), each connection is tracked separately.
on(), crud(), etc.) for the server's subscribe hook to fire. presence('room') alone shows you the list but doesn't register you as present unless you're also subscribed to room.Define message schemas per topic so event names and data shapes are validated at publish time. Catches typos and shape mismatches before they reach the wire - instead of silently sending garbage that the client ignores.
Authorization: typed channels validate shape, not identity. A schema check that the payload has
{id, text, done}does not prove the caller is allowed to publish on this channel. Gate the publish in your handler before invoking the channel. See Authorization model.
// src/lib/server/channels.js
import { createChannel } from 'svelte-adapter-uws/plugins/channels';
export const todos = createChannel('todos', {
created: (d) => ({ id: d.id, text: d.text, done: d.done }),
updated: (d) => ({ id: d.id, text: d.text, done: d.done }),
deleted: (d) => ({ id: d.id })
});
Each event maps to a validator function. The function receives the raw data and returns the validated (and optionally transformed) output. Throw to reject.
With Zod (or any library that exposes .parse()):
import { z } from 'zod';
import { createChannel } from 'svelte-adapter-uws/plugins/channels';
const Todo = z.object({ id: z.string(), text: z.string(), done: z.boolean() });
export const todos = createChannel('todos', {
created: Todo,
updated: Todo,
deleted: z.object({ id: z.string() })
});
import { todos } from '$lib/server/channels';
// In a form action or API route:
export async function POST({ request, platform }) {
const data = await request.json();
const todo = await db.save(data);
todos.publish(platform, 'created', todo); // validates, then publishes
todos.publish(platform, 'typo', todo); // throws: unknown event "typo"
todos.publish(platform, 'created', {}); // throws: validation failed (if validator rejects)
}
| Method | Description |
|---|---|
channel.publish(platform, event, data) |
Validate and broadcast to all subscribers |
channel.send(platform, ws, event, data) |
Validate and send to a single connection |
channel.topic |
The topic string |
channel.events |
Array of valid event names |
Validators can strip private fields before publishing. If your validator returns { id, text } but the input had { id, text, secret }, only id and text reach clients.
The client wrapper is optional - it catches event name typos on the receiving side too.
<script>
import { channel } from 'svelte-adapter-uws/plugins/channels/client';
const todos = channel('todos', ['created', 'updated', 'deleted']);
const all = todos.on(); // all events (same as on('todos'))
const created = todos.on('created'); // filtered (same as on('todos', 'created'))
const typo = todos.on('craeted'); // throws Error immediately
</script>
The events array is optional. Without it, .on() works exactly like the regular on() with the topic pre-filled - no validation, just convenience.
You can still use crud(), lookup(), latest(), etc. directly with the topic string. The client channel is purely additive.
.parse() method. You bring your own validation library (or use plain functions).Per-topic publish rate limiting. Wraps platform.publish() to coalesce rapid-fire updates (mouse position, typing indicators, live metrics). Sends the latest value at most once per interval. No timers to manage yourself.
Two modes:
throttle(ms) - sends immediately on first call (leading edge), then at most once per interval (trailing edge). Latest value wins within each interval.debounce(ms) - waits until no calls for the full interval, then sends the latest value. Each new call resets the timer.Authorization: throttle/debounce shape outbound publish rate, nothing else. The plugin does not check who is publishing or whether they are allowed to. Gate the publish in your handler. See Authorization model.
import { throttle, debounce } from 'svelte-adapter-uws/plugins/throttle';
const mouse = throttle(50); // at most once per 50ms per topic
const search = debounce(300); // wait for 300ms of silence
// Both factories accept an optional second argument with a `maxTopics`
// cap on the active topic registry (default 1_000_000). When the cap is
// reached, the oldest insertion-order topic is flushed (its pending
// value publishes immediately) and dropped before the new topic is
// inserted. Lower the cap if you want louder feedback when topic
// cardinality runs away.
const positions = throttle(50, { maxTopics: 10_000 });
// In hooks.ws.js
import { mouse, search } from '$lib/server/rate-limiters';
export function message(ws, { data, platform }) {
const msg = JSON.parse(Buffer.from(data).toString());
if (msg.type === 'cursor') {
// 60 mouse moves/sec from 20 users = 1200 publishes/sec
// With throttle(50), each topic publishes at most 20/sec
mouse.publish(platform, 'cursors', 'move', {
userId: ws.getUserData().id,
x: msg.x, y: msg.y
});
}
if (msg.type === 'search') {
// User types fast - only publish when they pause
search.publish(platform, 'search-results', 'query', { q: msg.q });
}
}
Rate limiting is per-topic. If you call mouse.publish() for topics 'room-a' and 'room-b', each topic has its own independent timer.
| Method | Description |
|---|---|
limiter.publish(platform, topic, event, data) |
Publish with rate limiting |
limiter.flush() |
Send all pending immediately, clear all timers |
limiter.flush(topic) |
Send pending for one topic |
limiter.cancel() |
Discard all pending, clear all timers |
limiter.cancel(topic) |
Discard pending for one topic |
limiter.interval |
The configured interval in ms |
t=0 publish({x:0}) --> sends immediately (leading edge)
t=10 publish({x:1}) --> stored (latest)
t=30 publish({x:2}) --> stored (overwrites x:1)
t=50 [timer fires] --> sends {x:2} (trailing edge)
t=60 publish({x:3}) --> stored
t=100 [timer fires] --> sends {x:3}
t=150 [timer fires] --> nothing pending, goes idle
t=200 publish({x:4}) --> sends immediately (new leading edge)
t=0 publish({q:"h"}) --> stored, timer starts
t=80 publish({q:"he"}) --> stored, timer resets
t=160 publish({q:"hel"}) --> stored, timer resets
t=260 [timer fires, 100ms] --> sends {q:"hel"}
setTimeout internally. Precision depends on Node.js event loop load (typically < 1ms drift).Token-bucket rate limiter for inbound WebSocket messages. Protects against spam, abuse, and runaway clients. Supports per-IP, per-connection, or custom key extraction, with optional auto-ban when a bucket is exhausted.
Different from throttle - throttle shapes outbound publish rate, rate limiting protects inbound against abuse.
Authorization: rate limiting is anti-abuse, not authorization. A bucket-exhaustion check answers "is this caller flooding?", not "is this caller allowed?". Identity-based access checks (role, ownership, tenant) still live in your handler. The two layers compose: gate auth first, then meter. See Authorization model.
// src/lib/server/ratelimit.js
import { createRateLimit } from 'svelte-adapter-uws/plugins/ratelimit';
export const limiter = createRateLimit({
points: 10, // 10 messages
interval: 1000, // per second
blockDuration: 30000 // auto-ban for 30s when exhausted
});
// src/hooks.ws.js
import { limiter } from '$lib/server/ratelimit';
export function message(ws, { data, platform }) {
const { allowed, remaining, resetMs } = limiter.consume(ws);
if (!allowed) return; // drop the message
// ... handle message normally
}
| Method | Description |
|---|---|
limiter.consume(ws, cost?) |
Deduct tokens (cost must be >= 0, defaults to 1), returns { allowed, remaining, resetMs } |
limiter.reset(key) |
Clear the bucket for a key |
limiter.ban(key, duration?) |
Manually ban a key |
limiter.unban(key) |
Remove a ban |
limiter.clear() |
Reset all state |
| Option | Default | Description |
|---|---|---|
points |
required | Tokens per interval (positive integer) |
interval |
required | Refill interval in ms |
blockDuration |
0 |
Auto-ban duration in ms when exhausted (0 = no auto-ban) |
keyBy |
'ip' |
'ip', 'connection', or (ws) => string |
maxBuckets |
1_000_000 |
Hard cap on retained buckets. Lazy expired-entry sweep runs first; the hard cap protects against sustained DDoS where every entry is unexpired. Oldest insertion-order entry is evicted on insert at cap. |
With keyBy: 'ip' (default), the limiter reads userData.remoteAddress, .ip, or .address. With keyBy: 'connection', each WebSocket gets its own bucket. Pass a function for custom grouping (e.g. by user ID or room).
Lightweight fire-and-forget broadcasting for transient state - mouse cursors, text selections, drag positions, drawing strokes. Built-in throttle with trailing edge ensures the final position always arrives. Auto-cleanup on disconnect.
Authorization: the cursor plugin broadcasts whatever the caller publishes to whoever is subscribed to the topic. It does not gate topic-subscribe or topic-publish. The
selectcallback chooses which fields of the publisher's userData to attach to the broadcast - it does not authorize. If a cursor topic should be limited (e.g.doc:42-> only doc-42 collaborators), enforce that in your subscribe/publish handler. See Authorization model.
// src/lib/server/cursors.js
import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
export const cursors = createCursor({
throttle: 16, // per-cursor: at most one broadcast per 16ms (~60 Hz)
topicThrottle: 16, // per-topic: coalesce all movers into one frame per 16ms
select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color })
// maxConnections: 1_000_000 (default) - hard cap on tracked connections
// maxTopics: 1_000_000 (default) - hard cap on active topic registry
});
Both throttle and topicThrottle default to 16 ms (~60 Hz). For a 120 Hz demo, halve them to 8. To disable per-topic coalescing entirely (every broadcast goes straight out), pass topicThrottle: 0. The two cap options bound internal Maps that grow with client behaviour. Eviction at cap drops the oldest insertion-order entry; for maxTopics the dropped topic's pending timers (per-cursor and topic-coalesce) are cleared first.
topicThrottle is the bandwidth lever for crowded rooms: rather than fan out one frame per cursor per tick, the server emits one bulk array per topic per window carrying every cursor that moved in that window. Bandwidth per peer scales with active-mover count, not with mover-count times per-mover rate.
Positions live on the update / bulk channel; user metadata lives on the catalog / join channel. The split keeps per-frame wire bytes minimal: a position frame is ~16 bytes per cursor (key + coords), and the user object (name, color, avatar, etc.) flows only when a user first appears.
| Event | Payload | Sent by |
|---|---|---|
catalog |
[{key, user}, ...] |
snapshot() - initial roster to a single new subscriber |
join |
{key, user} |
first update() on a (ws, topic) pair |
update |
{key, data} |
single-mover position frame |
bulk |
[{key, data}, ...] |
multi-mover coalesced position frame |
remove |
{key} |
remove() or hooks.close |
The cluster-aware extensions Redis-backed cursor speaks the same wire format, so the same client bundle works against either backend.
Use the hooks helper for zero-config cursor handling. The message hook handles cursor and cursor-snapshot messages automatically, and close calls remove(). The hooks verify that the sender is subscribed to the __cursor:{topic} channel before processing - clients that haven't passed the subscribe hook for that topic are silently rejected.
// src/hooks.ws.js
import { cursors } from '$lib/server/cursors';
export function message(ws, ctx) {
if (cursors.hooks.message(ws, ctx)) return;
// handle other messages...
}
export const close = cursors.hooks.close;
For custom auth or topic filtering, handle the messages manually:
export function message(ws, { data, platform }) {
const msg = JSON.parse(Buffer.from(data).toString());
if (msg.type === 'cursor') {
cursors.update(ws, msg.topic, { x: msg.x, y: msg.y }, platform);
}
if (msg.type === 'cursor-snapshot') {
cursors.snapshot(ws, msg.topic, platform);
}
}
export function close(ws, { platform }) {
cursors.remove(ws, platform);
}
<script>
import { cursor, move } from 'svelte-adapter-uws/plugins/cursor/client';
const positions = cursor('canvas');
function onmousemove(e) {
move('canvas', { x: e.clientX, y: e.clientY });
}
</script>
<div on:mousemove={onmousemove}>
{#each [...$positions] as [key, { user, data }] (key)}
<div
class="cursor-dot"
style="left: {data.x}px; top: {data.y}px; background: {user.color}"
>
{user.name}
</div>
{/each}
</div>
move(topic, data) is the recommended path for sending cursor updates. Calls are coalesced via requestAnimationFrame so even a 1000 Hz high-DPI mouse collapses to at most one send per repaint, matching the server-side topicThrottle default. Multi-topic callers do not clobber each other. No-op in non-browser environments.
The client store is a Readable<Map<string, { user, data }>>. The Map updates when cursors move, join, or disconnect. Internally the store merges the catalog/join stream (user metadata) with the update/bulk stream (positions); positions whose user has not yet been seen are withheld until the matching join arrives - they appear on the next render once the catalog catches up.
Initial sync and reconnect. The cursor(topic) store sends a { type: 'cursor-snapshot', topic } message every time the WebSocket connection opens - both on first connect and on every reconnect. The server calls cursors.snapshot(ws, topic, platform) in its message handler, which sends a catalog event (roster) followed by a bulk event (positions) back to the requesting client. Late joiners see existing cursors immediately. Wire cursors.snapshot() in your message handler as shown in the server example above.
The cursor() function accepts an optional second argument with a maxAge option (in milliseconds). When set, cursor entries that haven't received a position update within that window are automatically removed. This makes clients self-healing when the server fails to broadcast remove events under load:
const positions = cursor('canvas', { maxAge: 30_000 });
| Method | Description |
|---|---|
cursors.update(ws, topic, data, platform) |
Broadcast position (per-cursor + per-topic throttled). Emits join once per (ws, topic). |
cursors.remove(ws, platform) |
Remove from all topics, broadcast remove per topic |
cursors.snapshot(ws, topic, platform) |
Send current positions to one connection as catalog + bulk (initial sync) |
cursors.list(topic) |
Current positions (for SSR) |
cursors.clear() |
Reset all state and timers |
The cursor plugin uses two layers of throttle:
throttle caps how often a single user broadcasts on a single topic. Leading edge fires the first move immediately; subsequent moves within the window are stored and a trailing timer flushes the latest position at the window boundary.topicThrottle caps how often a topic emits a frame at all. Every move appends to the topic's dirty set and shares a single tracker-wide timer that fires once per cadence cycle. Multiple movers in the same window coalesce into one bulk array; a single mover in the window emits one update. There is no synchronous leading-edge fire: every flush goes through the tick, so movers arriving from different sockets (each a separate JS task in production) batch into the same frame regardless of how many task boundaries separate them.throttle: 16, topicThrottle: 16
t=0 A.update({x:0}) --> 'join' A (catalog channel)
position queued in topic dirty set
t=4 B.update({x:0}) --> 'join' B (catalog channel)
position queued in topic dirty set
t=8 A.update({x:5}) --> queued (entry-level throttle says wait until t=16)
t=16 [tick timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}]
Latency cost vs. the alternate "fire-the-first-mover-synchronously" design: the first mover on an idle topic waits up to topicThrottleMs before its frame leaves. At the default 16 ms (~60 Hz) that's one frame-budget; well below the perceptual floor for cursor. The cost buys cross-socket coalescing - without it, the first mover from each socket fragments out as its own single-cursor update because uWS dispatches each WS message as its own JS task and microtasks drain between dispatches.
Per-key async task queue with configurable concurrency and backpressure. With the default concurrency: 1, tasks are processed strictly in order per key - useful for sequential operations like collaborative editing, turn-based games, or transaction sequences. With concurrency > 1, dequeue order is preserved but tasks run in parallel, so completion order is not guaranteed.
Authorization: the queue serializes tasks by key; it does not decide who is allowed to enqueue under a given key. If your handler builds the queue key from a wire field without checking ownership (e.g.
key: payload.docId), an unauthorized client can interleave tasks into another tenant's queue and either DoS the owner with high-priority work or starve their queue with low-priority padding. Derive the queue key from a trusted prefix - e.g.\doc:${assertedDocId(ctx, payload.docId)}``. See Authorization model.
// src/lib/server/queue.js
import { createQueue } from 'svelte-adapter-uws/plugins/queue';
// Sequential processing per key (default concurrency: 1)
export const queue = createQueue({ maxSize: 100 });
// src/hooks.ws.js
import { queue } from '$lib/server/queue';
export async function message(ws, { data, platform }) {
const msg = JSON.parse(Buffer.from(data).toString());
// Messages for the same topic are processed one at a time
const result = await queue.push(msg.topic, async () => {
const record = await db.update(msg.data);
platform.publish(msg.topic, 'updated', record);
return record;
});
}
| Method | Description |
|---|---|
queue.push(key, task) |
Enqueue a task, returns promise with the task's return value |
queue.size(key?) |
Waiting + running count for a key, or total |
queue.clear(key?) |
Cancel waiting tasks (running tasks continue) |
queue.drain(key?) |
Wait for all tasks to complete |
| Option | Default | Description |
|---|---|---|
concurrency |
1 |
Max concurrent tasks per key |
maxSize |
1_000_000 |
Max waiting tasks per key (rejects when exceeded). Pass Infinity to disable the cap (not recommended at uWS scale). |
onDrop |
null |
Called with { key, task } when a task is rejected |
Different keys are independent - push('room-a', ...) and push('room-b', ...) run concurrently. Only tasks with the same key are queued.
clear() only rejects waiting tasks.Per-key critical-section primitive. Concurrent withLock(key, fn) calls on the same key run one at a time in FIFO order; calls on different keys run in parallel. Use this for atomic read-modify-write on user state, "only one in-flight upgrade per resource," or anywhere two concurrent requests racing the same record would corrupt it.
Backed by a per-key FIFO waiter queue: the holder runs fn until it settles, then the next waiter is promoted to head. Errors in one caller's fn propagate to that caller and do NOT block subsequent waiters.
Authorization:
withLock(key, fn)serializes whoever calls it under that key. The plugin does not check whether the caller is allowed to mutate the resource the key represents. If your handler interpolates a wire-supplied key, an attacker can grab a lock on a resource they don't own and stall any legitimate owner trying to acquire it (denial of service) - or worse, race ahead of legitimate write paths if your business logic assumes "I hold the lock therefore I have permission to mutate." Derive the lock key from a trusted prefix - e.g.\account:${assertedAccountId(ctx, payload.accountId)}``. See Authorization model.
// src/lib/server/locks.js
import { createLock } from 'svelte-adapter-uws/plugins/lock';
export const locks = createLock();
// Or with an explicit cap:
// export const locks = createLock({ maxKeys: 100_000 });
// src/routes/account/+page.server.js
import { locks } from '$lib/server/locks';
export const actions = {
topUp: async ({ request, locals }) => {
const amount = Number((await request.formData()).get('amount'));
// Two concurrent top-ups for the same user must not interleave.
return locks.withLock('user:' + locals.userId, async () => {
const user = await db.getUser(locals.userId);
user.balance += amount;
await db.saveUser(user);
return { balance: user.balance };
});
}
};
The lock holds until fn resolves (or rejects); the next waiter in line then runs. Different keys are independent - withLock('user:1', ...) and withLock('user:2', ...) run in parallel.
maxWaitMsThe third argument to withLock is an optional { maxWaitMs }. When set, the caller is rejected with a LOCK_TIMEOUT error if it does not acquire the lock within maxWaitMs milliseconds. The current holder's fn is not interrupted; only the waiting caller gives up. Subsequent waiters on the same key are unaffected - they continue in their original order, and a timeout never blocks the queue.
try {
return await locks.withLock('user:' + userId, work, { maxWaitMs: 5000 });
} catch (err) {
if (err.code === 'LOCK_TIMEOUT') {
// Surface a 503, retry elsewhere, fall back to a degraded path...
return new Response('busy', { status: 503 });
}
throw err;
}
The thrown error carries code: 'LOCK_TIMEOUT', key (the contended key), and maxWaitMs (the configured wait), so error-handler code can render a useful response without parsing the message string.
maxWaitMs: 0 fails immediately if any other caller currently holds or is queued ahead of you. Useful for "try-lock" patterns where you want to fall back instead of wait.
| Method | Description |
|---|---|
locks.withLock(key, fn, options?) |
Run fn with exclusive access to key. Returns the promise fn returns. Pass { maxWaitMs } to bound the wait. |
locks.held(key) |
true iff a lock is currently in flight for key (running fn or with at least one queued waiter). Observational only - do not branch on it to decide whether to acquire (the answer can change before your withLock call). |
locks.size() |
Number of keys with any in-flight or queued activity. |
locks.clear() |
Drop all in-flight tracking AND reject any pending waiters with a LOCK_CLEARED error. Currently-running fn calls are not interrupted; they finish normally. Use in tests / teardown. |
| Option | Default | Description |
|---|---|---|
maxKeys |
1_000_000 |
Hard cap on the number of distinct keys with any in-flight or queued activity. New-key withLock calls reject synchronously with "active key count exceeded" when the registry is at cap; existing keys can still be entered. Protects against unbounded key cardinality on lock-${userId} patterns. |
locks.withLock(key, ...) from inside a function already holding key queues behind the outer lock and never resolves. Avoid recursive locking; if you need it, derive a sub-key.SET NX or a database advisory lock.maxWaitMs caps how long a caller waits for the lock, but does NOT cap how long the holder retains it. A fn that hangs holds the lock until it resolves. Wrap fn in a timeout guard if you need to cap hold time.In-process key-value store with sliding TTL: every read or touch() extends an entry's lifetime by another full ttl window. Designed for the "load on WebSocket upgrade, refresh on activity" pattern - the upgrade handler reads a session by token, and any subsequent message keeps it alive while the user is active.
Use this when your auth layer hands you a token (cookie, header, query param) and you need a place to put the resolved session data without re-querying your database on every message. Pair with the dedup plugin if you want once-per-window semantics on side effects.
Authorization: the session store maps tokens to data. Producing the token-to-user binding (your auth layer) and validating the token before lookup (your
upgrade()hook or middleware) are NOT the plugin's job. If your handler callssessions.get(payload.token)with a wire-supplied token without first confirming the caller owns it, an attacker can lift any active token they happen to know and impersonate its owner. The plugin is a cache; the auth check belongs upstream. See Authorization model.
// src/lib/server/sessions.js
import { createSession } from 'svelte-adapter-uws/plugins/session';
// 30-minute sliding window.
export const sessions = createSession({ ttl: 30 * 60 * 1000 });
// src/hooks.server.js - populate on login.
import { sessions } from '$lib/server/sessions';
export const handle = async ({ event, resolve }) => {
if (event.url.pathname === '/login' && event.request.method === 'POST') {
const { username, password } = await event.request.formData();
const user = await db.authenticate(username, password);
if (user) {
const token = crypto.randomUUID();
sessions.set(token, { userId: user.id, role: user.role });
event.cookies.set('session_id', token, { path: '/', httpOnly: true });
}
}
return resolve(event);
};
// src/hooks.ws.js - read on upgrade, refresh on every message.
import { sessions } from '$lib/server/sessions';
export function upgrade({ cookies }) {
const token = cookies.session_id;
if (!token) return false;
const session = sessions.get(token); // get() also extends TTL
if (!session) return false;
return { token, userId: session.userId, role: session.role };
}
export function message(ws) {
// Keep the session alive on any client traffic
sessions.touch(ws.getUserData().token);
}
| Method | Description |
|---|---|
sessions.get(token) |
Look up by token. Returns the stored data if present and not expired, else null. On a hit, extends TTL (sliding window). Expired entries are removed lazily on access. |
sessions.set(token, data) |
Store or replace data for token. Resets the TTL. |
sessions.delete(token) |
Remove an entry. Returns true if the token was present (and not yet expired), false otherwise. |
sessions.touch(token) |
Extend TTL without reading data. Returns true if the entry was present and refreshed, false if missing / expired. |
sessions.size() |
Current number of retained entries (may include expired entries not yet pruned). |
sessions.clear() |
Remove all entries. |
| Option | Default | Description |
|---|---|---|
ttl |
required | Time to live in milliseconds. Each get / touch / set extends an entry's expiry to Date.now() + ttl. Must be positive. |
maxEntries |
10000 |
Soft cap on retained entries. When the map grows past 110% of this cap, expired entries are pruned in a single pass; if still over cap after pruning, the oldest insertion-order entries are evicted regardless. |
Named groups with explicit membership, roles, metadata, and lifecycle hooks. Like topics but with access control - you decide who can join, what role they have, and what happens when the group fills up or closes.
Authorization: the group's
onJoinhook is the place the join decision lives; the plugin itself does not authorize. Returning a role fromonJoinadmits the socket; throwing rejects. If youronJoinaccepts every caller and only relies onmaxMembersfor backpressure, the group is effectively public - which may be fine, but is your decision, not the plugin's. The "access control" framing above refers to the mechanism (membership lookup, roles, slot counts) you can wire up; the policy is yours. See Authorization model.
// src/lib/server/lobby.js
import { createGroup } from 'svelte-adapter-uws/plugins/groups';
export const lobby = createGroup('lobby', {
maxMembers: 50,
meta: { game: 'chess' },
onJoin: (ws, role) => console.log('joined as', role),
onFull: (ws, role) => {
// optionally notify the rejected client
}
});
Use the hooks helper for zero-config access control. The subscribe hook intercepts the internal __group:lobby topic, calls join(), and blocks the subscription if the group is full or closed. The close hook calls leave().
// src/hooks.ws.js
import { lobby } from '$lib/server/lobby';
export const { subscribe, unsubscribe, close } = lobby.hooks;
If you need custom logic (role selection, auth gating), wrap the hook:
// src/hooks.ws.js
import { lobby } from '$lib/server/lobby';
export function subscribe(ws, topic, ctx) {
if (topic === '__group:lobby') {
const role = ws.getUserData().isAdmin ? 'admin' : 'member';
return lobby.join(ws, ctx.platform, role) ? undefined : false;
}
lobby.hooks.subscribe(ws, topic, ctx);
}
export const { unsubscribe, close } = lobby.hooks;
Publish to group members:
// Broadcast to everyone
lobby.publish(platform, 'chat', { text: 'hello' });
// Broadcast only to admins
lobby.publish(platform, 'admin-alert', { msg: 'new report' }, 'admin');
<script>
import { group } from 'svelte-adapter-uws/plugins/groups/client';
const lobby = group('lobby');
const members = lobby.members;
</script>
<p>{$members.length} members</p>
The client store exposes two reactive values: the main store for events ($lobby - latest message) and .members for the live member list. The member list updates automatically on join, leave, and close events - no polling needed.
| Method | Description |
|---|---|
group.join(ws, platform, role?) |
Add member. Returns true or false if full/closed |
group.leave(ws, platform) |
Remove member |
group.publish(platform, event, data, role?) |
Broadcast (optionally filtered by role) |
group.send(platform, ws, event, data) |
Send to one member (throws if not a member) |
group.members() |
Array of { ws, role } |
group.count() |
Member count |
group.has(ws) |
Check membership |
group.close(platform) |
Dissolve group, notify everyone |
group.name |
Group name (read-only) |
group.meta |
Metadata (get/set) |
group.hooks |
Ready-made { subscribe, unsubscribe, close } hooks with access control |
Roles: 'member' (default), 'admin', 'viewer'.
| Option | Default | Description |
|---|---|---|
maxMembers |
Infinity |
Maximum members |
meta |
{} |
Initial metadata (shallow-copied) |
onJoin |
- | (ws, role) => void |
onLeave |
- | (ws, role) => void |
onFull |
- | (ws, role) => void |
onClose |
- | () => void |
send(). When filtering by role, the plugin iterates members and sends individually instead of using the topic broadcast. Fine for typical group sizes, but O(n) with member count.Deployment & scaling
uWebSockets.js is a native C++ addon, so your Docker image needs to match the platform it was compiled for. Build inside the container to be safe.
FROM node:22-trixie-slim AS build
# git is required - uWebSockets.js is installed from GitHub, not npm
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage - no git needed
FROM node:22-trixie-slim
WORKDIR /app
COPY --from=build /app/build build/
COPY --from=build /app/node_modules node_modules/
COPY package.json .
EXPOSE 3000
CMD ["node", "build"]
With TLS:
CMD ["sh", "-c", "SSL_CERT=/certs/cert.pem SSL_KEY=/certs/key.pem node build"]
With environment variables:
docker run -p 3000:3000 \
-e PORT=3000 \
-e ORIGIN=https://example.com \
my-app
Important: Use Debian Trixie or Ubuntu 24.04+ based images (glibc >= 2.38). Bookworm-based images (
node:*-slim,node:*-bookworm) ship glibc 2.36 which is too old for uWebSockets.js. Don't use Alpine either - uWebSockets.js binaries are compiled against glibc, not musl.
The adapter supports multi-core scaling with two modes, auto-selected based on platform.
Set the CLUSTER_WORKERS environment variable to enable it:
# Use all available CPU cores
CLUSTER_WORKERS=auto node build
# Fixed number of workers
CLUSTER_WORKERS=4 node build
# Combined with other options
CLUSTER_WORKERS=auto PORT=8080 ORIGIN=https://example.com node build
If a worker crashes, it is automatically restarted with exponential backoff (100ms initial, doubling up to 5s, max 50 attempts before the primary exits). On SIGTERM/SIGINT, the primary tells all workers to drain in-flight requests and shut down gracefully.
The primary thread monitors worker health with a 10-second heartbeat interval. If a worker fails to acknowledge a heartbeat within 30 seconds (stuck event loop, deadlock), the primary terminates it and the restart policy kicks in.
reuseport (Linux default) - each worker binds to the same port via SO_REUSEPORT. The kernel distributes incoming connections across all listening workers. There is no single-threaded acceptor bottleneck and no single point of failure - one worker crashing does not affect the others.
acceptor (macOS/Windows default) - a primary thread creates an acceptor app that receives all connections and distributes them to worker threads via uWS child app descriptors. Works on all platforms.
The mode is auto-detected. Override it explicitly if needed:
# Force acceptor mode on Linux (e.g. for debugging)
CLUSTER_MODE=acceptor CLUSTER_WORKERS=auto node build
Setting CLUSTER_MODE=reuseport on non-Linux platforms is an error (SO_REUSEPORT is not reliable outside Linux).
platform.publish() is automatically relayed across all workers via the primary thread, so subscribers on any worker receive the message. This is built in - no external pub/sub needed. The relay is microtask-batched: a SvelteKit action that calls publish() multiple times sends a single IPC message per microtask instead of one per call.
If you add your own cross-process messaging (Redis, Postgres LISTEN/NOTIFY, etc.), pass { relay: false } to prevent duplicate delivery - your external source already fans out to every worker, so the built-in relay would double it.
Per-worker limitations (acceptable for most apps):
platform.connections - returns the count for the local worker onlyplatform.subscribers(topic) - returns the count for the local worker onlyplatform.sendTo(filter, ...) - iterates the local worker's connections only, no cross-worker relayplatform.closedWsAborts - per-worker counter; sum across workers for cluster totalplatform.assertions - per-worker counter MapOn Linux, SO_REUSEPORT is set on every app.listen() call - including single-process mode. This means multiple independent node build processes can bind to the same port without any adapter-level clustering. The kernel distributes connections across them.
If you already have external pub/sub (Redis, Postgres LISTEN/NOTIFY) handling cross-process messaging, you do not need CLUSTER_WORKERS at all. Just run multiple replicas and let your infrastructure handle the rest:
# docker-compose.yml
services:
app:
build: .
command: node build
network_mode: host
environment:
- PORT=443
- SSL_CERT=/certs/cert.pem
- SSL_KEY=/certs/key.pem
deploy:
replicas: 4
Each replica is a plain single-process node build. No coordinator thread, no built-in relay. Docker handles restarts, Redis handles cross-process messaging, the kernel handles port sharing.
With network_mode: host, containers share the host network stack directly - no port mapping needed, and services like Postgres and Redis are reachable via 127.0.0.1. This avoids Docker bridge DNS and gives the best network performance.
When to use what:
CLUSTER_WORKERS - single-machine deployments without Docker/k8s/systemd managing processes for youuWebSockets.js can handle hundreds of thousands of connections per process, but Linux defaults are conservative. For any deployment expecting more than a few hundred concurrent WebSocket connections, apply these settings on the host machine.
Add to /etc/sysctl.conf and run sysctl -p:
net.ipv4.tcp_max_syn_backlog = 4096 # pending TCP connection queue
net.ipv4.tcp_tw_reuse = 1 # reuse TIME_WAIT sockets faster
net.core.somaxconn = 4096 # listen() backlog limit
fs.file-max = 1024000 # system-wide file descriptor limit
net.netfilter.nf_conntrack_max = 262144 # connection tracking table size (default 65536 fills up fast under load, drops ALL new TCP including SSH)
net.ipv4.tcp_fastopen = 3 # TCP Fast Open for both client and server (saves 1 RTT on reconnecting clients)
net.ipv4.tcp_defer_accept = 5 # don't wake the app until data arrives (ignores port scanners and half-open probes)
TCP Fast Open (tcp_fastopen = 3) lets a returning client send data in the SYN packet, eliminating one round-trip for the first request after a short idle. Browsers and HTTP clients that support TFO will use it automatically. The value 3 enables it for both incoming (server) and outgoing (client) connections.
TCP Defer Accept (tcp_defer_accept = 5) keeps the kernel from delivering the accepted socket to the application until data arrives. Port scanners, SYN probes, and clients that open a TCP connection but send nothing are handled at the kernel level rather than consuming event loop time. The value is the timeout in seconds before a data-less connection is dropped.
Add to /etc/security/limits.conf (takes effect on next login):
* soft nofile 1024000
* hard nofile 1024000
root soft nofile 1024000
root hard nofile 1024000
The wildcard * does not apply to the root user on most Linux distributions. If the app runs as root (common in Docker), the explicit root lines are required.
If running in Docker, the container also needs raised limits. Add to your docker-compose.yml:
services:
app:
ulimits:
nofile:
soft: 65536
hard: 65536
Without these changes, each process is limited to 1024 file descriptors (the default). Each WebSocket connection uses one file descriptor, so the default caps you at roughly 1000 concurrent connections per process. The server CPU can be well under 50% and you will still hit this ceiling - the bottleneck is the OS, not uWS or your application code.
For a deeper walkthrough, see Millions of active WebSockets with Node.js from the uWebSockets.js authors.
If you run a stress test from your local machine against a remote server, every WebSocket connection goes through your home router's NAT table. Home routers typically have 1024 to 4096 NAT entries. Once the table fills up, the router drops ALL new outbound connections - not just your test, but SSH, your phone on WiFi, everything on your network.
Symptoms of NAT table exhaustion:
The fix: run the stress test from the server itself (localhost to localhost) or from a machine on the same network as the server. This bypasses NAT entirely and lets you hit the actual server limits.
uWebSockets.js manages connection lifecycle at the C++ level. These are its built-in behaviors:
HTTP keepalive: uWS closes idle HTTP connections after 10 seconds of inactivity. This is compiled into the C++ layer and is not configurable from JavaScript. Behind a reverse proxy (nginx, Caddy, Cloudflare), the proxy manages keepalive for external clients; uWS handles only the proxy-to-app leg.
Slow-loris protection: uWS requires at least 16 KB/second of throughput from each HTTP client. Connections that send data slower than this (a common DoS technique) are dropped by the C++ layer before they reach your application code.
WebSocket ping/pong: Set idleTimeout in the adapter's websocket option (in seconds) to have uWS send automatic WebSocket ping frames and close connections that don't respond. The default is 120 seconds. The client store handles pong automatically.
// svelte.config.js
adapter({
websocket: {
idleTimeout: 120, // close WS connections silent for 120s
maxPayloadLength: 16 * 1024 * 1024 // max incoming WS message size
}
})
uWebSockets.js is a C++ HTTP and WebSocket server compiled to a native V8 addon. It consistently outperforms Node.js' built-in http module, Express, Fastify, and every other JavaScript HTTP server by a significant margin.
We ran a comprehensive benchmark suite isolating every layer of overhead - from barebones uWS through the full adapter pipeline - and compared against @sveltejs/adapter-node (Node http + Polka + sirv) and the most popular WebSocket libraries (socket.io, ws). The benchmark code is in the bench/ directory so you can reproduce it yourself.
Tested with a trivial SvelteKit handler (isolates adapter overhead from your app code):
| adapter-uws | adapter-node | Multiplier | |
|---|---|---|---|
| Static files | 165,700 req/s | 24,500 req/s | 6.8x faster |
| SSR | 150,500 req/s | 58,300 req/s | 2.6x faster |
100 connections, 10 pipelining, 10s, 2 runs averaged. Node v24, Windows 11.
The static file gap is the largest because adapter-node uses sirv which calls fs.createReadStream().pipe(res) per request, while we serve from an in-memory Map with a single res.cork() + res.end(). The SSR gap comes from uWS's C++ HTTP parsing and batched writes vs Node's async drain event cycle.
50 connected clients, 10 senders, burst mode, 8 seconds:
| Server | Messages delivered/s | vs adapter-uws |
|---|---|---|
| uWS native (barebones) | 3,583,000 | baseline |
| adapter-uws (full handler) | 3,583,000 | 1.0x |
| ws library | 232,200 | 15.4x slower |
| socket.io | 226,700 | 15.8x slower |
uWS native pub/sub delivered 3.5M messages/s with exact 50x fan-out. The adapter matches it - the byte-prefix check and string template envelope add near-zero overhead to the hot path. socket.io and ws both collapsed under the same load, delivering less than 1x fan-out (massive message loss/queueing).
HTTP (SSR path) - ~32% total overhead vs barebones uWS:
| Layer | Cost | Notes |
|---|---|---|
res.cork() + status + headers |
~12.6% | Writing a proper HTTP response - unavoidable |
new Request() construction |
~9% | Required by SvelteKit's server.respond() contract |
| async/Promise scheduling | ~3% | getReader() + read() + event loop yield |
| Header collection, remoteAddress | ~1% | req.forEach + TextDecoder |
WebSocket - at parity with barebones uWS pub/sub:
| Layer | Cost | How |
|---|---|---|
| Subscribe/unsubscribe check | ~0% | Byte-prefix discriminator: byte[3] is y for {"ty (control) and o for {"to (user envelope). One comparison skips JSON.parse for all user messages (0.001us per message). |
| Envelope wrapping | ~0% | String template + esc() char scan instead of JSON.stringify on a wrapper object. Only data is stringified. ~0.085us per publish. |
| Connection tracking | ~2% | Set add/delete on open/close. |
| Origin validation, upgrade headers | ~2% | Four req.getHeader calls on upgrade. |
What we don't add:
fs.createReadStream)http.IncomingMessage shim (we construct Request directly from uWS)The adapter applies several allocation and caching strategies to stay off the GC's radar on the hot path:
{ aborted: false } state object. Instead of allocating one per request (which promotes to V8's old generation and stays there), the adapter maintains a pool of up to 256 reusable state objects. Eliminates young-gen GC churn under sustained load.platform.publish() and platform.send() wrap data in a {"topic":"...","event":"...","data":...} envelope. The prefix up to "data": is cached in a 256-entry LRU map keyed by topic+event. Repeated publishes to the same topic/event (the common case) skip 4 string concatenations and the character validation scan. The cache is trimmed every 60 seconds to reclaim stale entries from shifted traffic patterns.When multiple concurrent requests arrive for the same anonymous (no cookie/auth) GET or HEAD URL, only one is dispatched to SvelteKit. The others wait for the result and reconstruct their own response from the shared buffer. This prevents redundant rendering work during traffic spikes, a common pattern when a post goes viral or a cron job hits a popular page at the same time as real users.
Dedup is automatically skipped for:
Cookie or Authorization header (personalized responses must not be shared)Set-Cookie header (personalized)X-No-Dedup: 1 header (opt-out escape hatch)No configuration is needed. The dedup map holds at most 500 in-flight keys simultaneously as a safety valve against memory pressure from unique URLs.
Vary and personalization contract: The adapter deduplicates by method + URL only. It cannot inspect every possible input that might affect your response (user-agent quirks, custom headers, etc.). The contract is:
Vary header listing those headers. The adapter checks the Vary header after rendering and discards the dedup entry if Vary is present, preventing that response from being shared.X-No-Dedup: 1 to opt out entirely.Anonymous GET/HEAD routes that produce the same output for all users (landing pages, docs, prerendered pages) benefit most from dedup and require no action.
Measured benefit: 200 concurrent requests to the same anonymous URL with a 5ms render delay: without dedup, 200 render calls; with dedup, 1 render call. 200x reduction in CPU and memory pressure.
The adapter retains ~68% of raw uWS HTTP throughput and matches uWS native WebSocket throughput. The HTTP overhead is dominated by things SvelteKit requires (new Request(), proper HTTP headers). The WebSocket overhead is now almost entirely the JSON.stringify of your data payload - the adapter's own machinery costs near zero. In a real app, your load functions and component rendering will dwarf all of this - the adapter's job is to get out of the way, and it does.
To run the benchmarks yourself:
npm install # installs uWebSockets.js, autocannon, etc.
node bench/run.mjs # adapter overhead breakdown
node bench/run-compare.mjs # full comparison vs adapter-node + socket.io
node bench/run-dedup.mjs # SSR dedup render-call reduction
Examples
Here's a complete example tying everything together.
svelte.config.js
import adapter from 'svelte-adapter-uws';
export default {
kit: {
adapter: adapter({
websocket: true
})
}
};
vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import uws from 'svelte-adapter-uws/vite';
export default {
plugins: [sveltekit(), uws()]
};
src/routes/todos/+page.server.js
import { db } from '$lib/server/db.js';
export async function load() {
return { todos: await db.getTodos() };
}
export const actions = {
create: async ({ request, platform }) => {
const text = (await request.formData()).get('text');
const todo = await db.createTodo(text);
platform.topic('todos').created(todo);
},
toggle: async ({ request, platform }) => {
const id = (await request.formData()).get('id');
const todo = await db.toggleTodo(id);
platform.topic('todos').updated(todo);
},
delete: async ({ request, platform }) => {
const id = (await request.formData()).get('id');
await db.deleteTodo(id);
platform.topic('todos').deleted({ id });
}
};
src/routes/todos/+page.svelte
<script>
import { crud, status } from 'svelte-adapter-uws/client';
let { data } = $props();
const todos = crud('todos', data.todos);
</script>
{#if $status === 'open'}
<span>Live</span>
{/if}
<form method="POST" action="?/create">
<input name="text" placeholder="New todo..." />
<button>Add</button>
</form>
<ul>
{#each $todos as todo (todo.id)}
<li>
<form method="POST" action="?/toggle">
<input type="hidden" name="id" value={todo.id} />
<button>{todo.done ? 'Undo' : 'Done'}</button>
</form>
<span class:done={todo.done}>{todo.text}</span>
<form method="POST" action="?/delete">
<input type="hidden" name="id" value={todo.id} />
<button>Delete</button>
</form>
</li>
{/each}
</ul>
Open the page in two browser tabs. Create, toggle, or delete a todo in one tab - it appears in the other tab instantly.
Help
You need the Vite plugin. Without it, there's no WebSocket server running during npm run dev.
vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import uws from 'svelte-adapter-uws/vite';
export default {
plugins: [sveltekit(), uws()]
};
Also make sure ws is installed:
npm install -D ws
This means event.platform is undefined. Two possible causes:
Cause 1: Missing Vite plugin in dev mode
Same fix as above - add uws() to your vite.config.js.
Cause 2: Calling platform on the client side
event.platform only exists on the server. If you're calling it in a +page.svelte or +layout.svelte file, move that code to +page.server.js or +server.js.
// WRONG - +page.svelte (client-side)
platform.publish('todos', 'created', todo);
// RIGHT - +page.server.js (server-side)
export const actions = {
create: async ({ platform }) => {
platform.publish('todos', 'created', todo);
}
};
Your upgrade handler is returning false, which rejects the connection with 401. The client store's auto-reconnect then tries again, gets rejected again, and so on.
To debug, enable debug mode on the client:
import { connect } from 'svelte-adapter-uws/client';
connect({ debug: true });
Then check the browser's Network tab -> WS tab. You'll see the upgrade request and its 401 response.
Common causes:
cookies.session vs cookies.session_id)sameSite: 'strict' can block cookies on cross-origin navigations - try 'lax' if you're redirecting from an external siteTo stop the retry loop when credentials are permanently invalid, close the WebSocket with a terminal close code from inside your open or message handler. The client will not reconnect on these codes:
| Code | Meaning |
|---|---|
1008 |
Policy Violation (standard) |
4401 |
Unauthorized (custom) |
4403 |
Forbidden (custom) |
// src/hooks.ws.js
export async function open(ws, { platform }) {
const userData = ws.getUserData();
if (!userData.userId) {
ws.close(4401, 'Unauthorized'); // client will not retry
return;
}
}
When the server closes with code 4429, the client treats it as a rate limit signal and backs off more aggressively before retrying.
npm run preview"This is expected. SvelteKit's preview server is Vite's built-in HTTP server - it doesn't know about WebSocket upgrades. Use node build instead:
npm run build
node build
uWebSockets.js is a native C++ addon. It's installed from GitHub, not npm, and needs to compile for your platform.
# Make sure you're using the right install command (no uWebSockets.js@ prefix)
npm install uNetworking/uWebSockets.js#v20.60.0
On Windows: Make sure you have the Visual C++ Build Tools installed. You can get them from the Visual Studio Installer (select "Desktop development with C++").
On Linux: Make sure build-essential is installed:
sudo apt install build-essential
On Docker: Use a Trixie-based image with git:
FROM node:22-trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
Turn on debug mode. It logs every WebSocket event to the browser console:
<script>
import { connect } from 'svelte-adapter-uws/client';
// Call this once, anywhere - it's a singleton
connect({ debug: true });
</script>
You'll see output like:
[ws] connected
[ws] subscribe -> todos
[ws] <- todos created {"id":1,"text":"Buy milk"}
[ws] disconnected
[ws] resubscribe -> todos
Make sure the topic names match exactly between server and client:
// Server
platform.publish('todos', 'created', todo); // topic: 'todos'
// Client - must match exactly
const todos = on('todos'); // 'todos' - correct
const todos = on('Todos'); // 'Todos' - WRONG, case sensitive
const todos = on('todo'); // 'todo' - WRONG, singular vs plural
Every message sent through platform.publish() or platform.topic().created() arrives as JSON with this shape. The envelope is constructed with string concatenation for speed, but topic and event are validated first - if either contains a quote, backslash, or control character, the call throws instead of producing malformed JSON:
{
"topic": "todos",
"event": "created",
"data": { "id": 1, "text": "Buy milk", "done": false },
"seq": 42
}
The seq field is a monotonic per-topic sequence number stamped automatically on every platform.publish(). The first publish to a topic sends seq: 1, the next seq: 2, and so on; each topic has its own counter. Reconnecting clients can use the seq to detect dropped frames and resume from where they left off. Pass { seq: false } to skip stamping when you don't care about gap detection or when topic cardinality is unbounded:
// Standard publish - seq stamped automatically
platform.publish('chat', 'message', msg);
// Opt out for ephemeral or high-cardinality topics
platform.publish(`cursor:${userId}`, 'move', pos, { seq: false });
Clustering: the per-topic counter is worker-local. Each worker stamps its own publishes; relayed messages from other workers pass through with the originating worker's seq. For cluster-wide monotonic seq across all workers, wire up the Redis Lua INCR variant from the extensions package.
The client store parses this automatically. When you use on('todos'), the store value is:
{ topic: 'todos', event: 'created', data: { id: 1, text: 'Buy milk', done: false }, seq: 42 }
When you use on('todos', 'created'), you get the payload wrapped in { data }:
{ data: { id: 1, text: 'Buy milk', done: false } }
Your reverse proxy needs to forward WebSocket upgrade requests. Here's a complete nginx config that handles both your app and WebSocket:
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# WebSocket - must be listed before the catch-all
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Everything else - your SvelteKit app
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Then run your app with:
PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=host ADDRESS_HEADER=x-forwarded-for node build
For Caddy, it just works - Caddy proxies WebSocket upgrades automatically, no special config needed:
example.com {
reverse_proxy localhost:3000
}
Set it in both the adapter config and the client:
svelte.config.js
adapter({
websocket: {
path: '/my-ws'
}
})
Client
import { connect } from 'svelte-adapter-uws/client';
connect({ path: '/my-ws' });
Or if you're using on() directly (which auto-connects), call connect() first:
<script>
import { connect, on } from 'svelte-adapter-uws/client';
// Set the path before any on() calls
connect({ path: '/my-ws' });
const todos = on('todos');
</script>
npm test # 777 unit tests (vitest, ~2s)
npm run test:e2e # 25 e2e tests (playwright, ~13s)
npm run test:coverage # both + coverage reports (~30s)
Unit tests cover store patterns, adapter options, plugin logic, client behavior, and the WebSocket test harness. They run in vitest with the vmForks pool.
E2e tests start a real SvelteKit app (test/fixture/) with the adapter installed via file:../... Playwright runs two projects:
vite dev with the Vite plugin. Tests SSR, static files, WebSocket pub/sub (via ws clients), and the real client.js running in Chromium.vite build + node build/index.js through uWebSockets.js. Tests the same surface against the production runtime, plus the health check endpoint and 404 handling.The coverage script collects V8 coverage from both the Playwright server processes (vite.js, handler.js) and the browser (client.js via Chrome DevTools Protocol), then reports them alongside the vitest unit coverage.
First-time setup for e2e:
cd test/fixture && npm install && cd ../..
npx playwright install chromium
The svelte-adapter-uws/testing entry point provides createTestServer() for integration-testing your hooks.ws handlers against a real uWebSockets.js server:
import { createTestServer } from 'svelte-adapter-uws/testing';
import { WebSocket } from 'ws';
import { describe, it, expect, afterEach } from 'vitest';
import * as myHandler from '../src/hooks.ws.js';
let server;
afterEach(() => server?.close());
it('rejects unauthenticated upgrades', async () => {
server = await createTestServer({ handler: myHandler });
const ws = new WebSocket(server.wsUrl);
const code = await new Promise((resolve) => {
ws.on('unexpected-response', (_, res) => resolve(res.statusCode));
ws.on('open', () => resolve('open'));
});
expect(code).toBe(401);
});
it('publishes to subscribers', async () => {
server = await createTestServer({ handler: myHandler });
const ws = new WebSocket(server.wsUrl, {
headers: { cookie: 'session=valid-token' }
});
await new Promise(r => ws.on('open', r));
ws.send(JSON.stringify({ type: 'subscribe', topic: 'todos' }));
await new Promise(r => setTimeout(r, 10));
const msg = new Promise(r => ws.on('message', d => r(JSON.parse(d.toString()))));
server.platform.publish('todos', 'created', { id: 1 });
expect(await msg).toMatchObject({ topic: 'todos', event: 'created' });
ws.close();
});
The test server starts on a random port (typically in ~2ms), uses the same subscribe/unsubscribe protocol as production, and exposes the full Platform API (publish, send, sendTo, topic, connections, subscribers, assertions).
createTestServer optionsserver = await createTestServer({
handler: myHandler,
// Mirror of the production wsOptions; pass either to test the same
// behaviour your production app gets.
upgradeAdmission: { maxConcurrent: 100, perTickBudget: 16 },
// Other production-equivalents available:
// wsOptions: { maxBackpressure, idleTimeout, maxPayloadLength, ... },
// origin: '*' | 'same-origin' | string[],
// env: { ... } // ENV_PREFIX-aware env shim for the SvelteKit `platform.env`
});
upgradeAdmission is the same { maxConcurrent, perTickBudget } shape the production handler accepts via adapter({ websocket: { upgradeAdmission: ... } }). Passing it to createTestServer lets you assert admission shedding (503 responses on the upgrade path) end-to-end without booting a full SvelteKit app.
svelte-adapter-uws/testingDownstream test code (extensions, app-side integration tests, custom transport bridges) often needs to assert on the same wire shapes the production runtime produces. The testing entry point re-exports a curated set of pure helpers and userData slot constants so you don't redeclare helpers that would drift over time:
import {
createTestServer,
// wire-protocol helpers
esc, completeEnvelope, wrapBatchEnvelope,
isValidWireTopic, createScopedTopic,
// behaviour helpers
collapseByCoalesceKey, resolveRequestId, createChaosState,
// per-connection userData slot constants (use as Symbol keys on userData)
WS_SUBSCRIPTIONS, WS_COALESCED, WS_SESSION_ID,
WS_PENDING_REQUESTS, WS_STATS, WS_PLATFORM,
WS_CAPS, WS_REQUEST_ID_KEY
} from 'svelte-adapter-uws/testing';
Production-internal plumbing (mime lookup, byte parsing, cookie split, write-chunk backpressure, sampler internals, upgrade admission factory, origin allowlist matcher) is deliberately NOT re-exported so the test surface can stay stable while production hot paths remain free to refactor.
The test platform also carries __chaos(cfg) for simulating broken-network conditions. Use it to verify that protocol code (subscribe acks, session resume, sendCoalesced, request/reply) recovers from message loss and slow consumers without changing the test fixture's hooks.
// Verify the client store's reconnect path delivers buffered seqs
// after a 30% packet-loss episode.
server.platform.__chaos({ scenario: 'drop-outbound', dropRate: 0.3 });
for (let i = 0; i < 100; i++) {
server.platform.publish('feed', 'tick', { i });
}
await new Promise(r => setTimeout(r, 200));
server.platform.__chaos(null); // back to normal delivery
// The client received some subset of the 100 ticks; on reconnect,
// the resume protocol should fill the gap.
// Stretch the wire by 50ms per frame to exercise sendCoalesced
// drop semantics under backpressure.
server.platform.__chaos({ scenario: 'slow-drain', delayMs: 50 });
// Reorder publishes within a 50ms jitter window: each frame waits
// an independently-random delay before reaching subscribers.
// Adjacent frames can arrive out of order, exercising seq-gap
// detection and idempotency-key handling.
server.platform.__chaos({ scenario: 'ipc-reorder', maxJitterMs: 50 });
// Simulate a worker process restart: close all live WebSocket
// connections with a clean close frame. The server stays up and
// keeps accepting new connections, so the test can immediately
// observe the client's reconnect + resume behavior.
server.platform.__chaos({ scenario: 'worker-flap' });
// Or with a custom close code / reason:
server.platform.__chaos({ scenario: 'worker-flap', code: 4001, reason: 'maintenance' });
Continuous scenarios (consulted on every outbound frame):
drop-outbound - discards outbound frames before they reach the wire with the configured dropRate (a probability in [0, 1]). Affects every server-to-client frame: platform.publish, platform.send, platform.sendTo, platform.request, the welcome envelope, subscribe acks, and the resumed ack.slow-drain - defers outbound frames by delayMs milliseconds via setTimeout. Order is preserved per call site (every frame waits the same delay).ipc-reorder - defers each outbound frame by an independently-random delay in [0, maxJitterMs). Adjacent frames can arrive out of order, simulating cross-worker relay reordering or queue jitter. maxJitterMs is capped at 60_000.One-shot trigger (does NOT change continuous chaos state):
worker-flap - closes every currently-live WebSocket connection with a clean close frame. Defaults to code: 1012 ("server restart") and reason: 'worker restart'; both are configurable. The server stays up and accepts new connections; an active continuous scenario (e.g. drop-outbound) survives the flap and applies to subsequent frames. Use to verify clients reconnect, present their resume token, and your resume hook fills the gap correctly.Pass null (or call __chaos() with no argument) to clear the active continuous scenario; the harness returns to its zero-overhead fast paths. While a scenario is active, platform.publish switches from uWS's C++ TopicTree fan-out to a JS-side fanout so the chaos state can intercept per recipient.
Note:
__chaoslives on the test platform only. The production runtime does not ship the harness; chaos belongs in test files, not user code.
__chaos is a WebSocket-frame outbound chokepoint - it intercepts what the test harness sends to its connected WS clients, and only that. Transport-level traffic to anything else you've wired up alongside the adapter (an ioredis client for cross-instance pub/sub, a pg connection for LISTEN/NOTIFY, a NATS subscription, a custom HTTP backend) does NOT pass through sendOutboundT and is untouched by the harness.
This is intentional: each layer's chaos surface stays cohesive with what that layer actually owns. The adapter knows its WS wire and ships chaos for that. Each backend / extension knows its own wire and is the right place to wrap that wire's client.
For cross-wire fault injection, the createChaosState factory re-exported from svelte-adapter-uws/testing is the same primitive __chaos uses internally. Wrap any transport client with it and you get the same __chaos({ scenario, dropRate, delayMs }) ergonomic, scoped to that client. The pattern is one helper:
import { createChaosState } from 'svelte-adapter-uws/testing';
// Wrap any transport client (ioredis, pg, NATS, fetch, ...) so its
// outbound calls become chaos-controllable from test code. Same shape
// as __chaos on the test platform, scoped to this one client.
function makeChaosClient(client, methodName = 'publish') {
const chaos = createChaosState();
const original = client[methodName].bind(client);
return new Proxy(client, {
get(target, prop, receiver) {
if (prop === '__chaos') return (cfg) => chaos.set(cfg);
if (prop !== methodName) return Reflect.get(target, prop, receiver);
return async (...args) => {
if (chaos.shouldDropOutbound()) return 0;
const delay = chaos.getDelayMs();
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
return original(...args);
};
}
});
}
// Test:
const redis = makeChaosClient(realRedisClient, 'publish');
redis.__chaos({ scenario: 'drop-outbound', dropRate: 0.3 });
// ... drive the system, assert it tolerates 30% Redis publish loss ...
redis.__chaos(null);
Composes across transports: makeChaosClient(pgClient, 'query'), makeChaosClient(natsClient, 'publish'), etc. Zero new adapter surface; downstream extensions that need cross-wire fault injection own their own wrappers.