Platform API
Available in server hooks, load functions, form actions, API routes, and WebSocket hooks (hooks.ws).
Access it via event.platform in SvelteKit handlers or destructured from the second argument in WebSocket hooks.
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. The validation is a single-pass char scan and adds no measurable overhead.
// 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 };
}
}; Cluster mode relay
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 });
}); Message protocol
The adapter uses a JSON envelope format for all pub/sub messages: { topic, event, data }. Control messages from the client store (subscribe, unsubscribe, subscribe-batch) use { type, topic } or { type, topics }.
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).
Topic validation
Topics submitted by clients are validated before being accepted:
- Must be between 1 and 256 characters
- Must not contain control characters (code points below 32)
subscribe-batchaccepts at most 256 topics per message (the client only sends what it was subscribed to before a reconnect)
Topics prefixed with __ are reserved for adapter plugins (presence uses __presence:*, replay uses __replay:*). They are not blocked at the protocol level because plugins subscribe to them from the client, but application code should not use the __ prefix for its own topics.
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, isBinary, msg, platform }) {
if (msg) {
// adapter 0.5.3+: msg is the pre-parsed envelope when the frame
// looked like {"type":"...",...} but didn't match a control type.
// Skip the second TextDecoder + JSON.parse on the dispatch path.
platform.send(ws, 'echo', 'reply', { got: msg });
return;
}
// Fallback for binary / non-envelope text frames
const parsed = JSON.parse(Buffer.from(data).toString());
platform.send(ws, 'echo', 'reply', { got: parsed });
} Since adapter 0.5.3, the message hook context carries an optional msg field with the pre-parsed JSON envelope when the adapter already parsed the frame for control-message routing (subscribe / unsubscribe / hello / resume / reply / subscribe-batch) but no control type matched. Plugin-layer dispatchers can skip a second TextDecoder + JSON.parse on the same frame. The field is undefined for binary frames, prefix-misses, parse failures, or frames that parse to a non-object. See createMessage({ onJsonMessage }) for the realtime-side convenience.
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.
Clustering:
sendToiterates the local worker’s connections only. There is no cross-worker relay forsendTo. For cross-worker targeted messaging, preferpublish()with a user-specific topic.
platform.connections
Number 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}`)
});
} Cluster note. Returns the local count only - the number of connections subscribed to the topic on THIS worker / instance. For cluster-wide counts, pair
createShardedBuswithcreatePublishRateAggregatorand readbus.subscribers(topic), which delegates to the aggregator’ssubscribersOf(topic)rolled across non-stale remote slices.
platform.assertions
Per-category counter of framework invariant violations. The adapter ships internal hard-asserts at roughly 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] <category> line is logged.
Most apps will never see a non-empty entry. 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 surrounding log context.
export async function GET({ platform }) {
const assertions = {};
for (const [category, count] of platform.assertions) {
assertions[category] = count;
}
return json({ healthy: Object.keys(assertions).length === 0, assertions });
} The returned Map<string, number> is the live module-level instance - read-only, do not mutate.
Test mode vs production. 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. The realtime layer ships a parallel surface (getAssertionCounters() plus svelte_realtime_assertion_violations_total{category} for Prometheus). See Architecture -> Production assertions for the framework-wide picture.
platform.closedWsAborts
Per-worker count of best-effort uWS operations that aborted because the underlying WebSocket had already closed. Added in adapter 0.5.5. 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 - extensions 0.5.6 surfaces closedWsAborts as a live getter on bus-wrapped platforms so the value is the same on the wrapped seam as on the source.
Monotonic, per-worker, reset only on process restart.
For closed-WS errors that DO surface (plugin attach paths like presence.join and cursor.attach), see WsClosedError in the extensions.
platform.topic(name)
Scoped helper that reduces repetition when publishing multiple events to the same topic.
CRUD methods
Calling .created(), .updated(), or .deleted() is shorthand for platform.publish(name, 'created', data), etc.:
// 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 });
}
}; Counter methods
The topic helper also has counter methods for numeric state:
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 } | Method | Event published | Data |
|---|---|---|
.created(data) | created | The data argument |
.updated(data) | updated | The data argument |
.deleted(data) | deleted | The data argument |
.set(value) | set | The value |
.increment(n?) | increment | n (default 1) |
.decrement(n?) | decrement | n (default 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.
Summary
| Method | Description |
|---|---|
publish(topic, event, data, options?) | Broadcast to all subscribers of a topic |
send(ws, topic, event, data) | Send to a single connection |
sendTo(filter, topic, event, data) | Send to connections matching a filter |
connections | Number of active WebSocket connections |
subscribers(topic) | Number of subscribers on a topic |
topic(name) | Scoped helper with .created(), .updated(), .deleted(), .set(), .increment(), .decrement() |
batch(messages) | Publish multiple messages in one call |
Authorized subscribe gates
Direct ws.subscribe() calls intentionally bypass the subscribe / subscribeBatch hooks - that is the bypass these methods guard against. Use the platform-side gates when server code needs to subscribe a connection on behalf of a user (e.g. routing a private room from inside an RPC handler).
platform.subscribe(ws, topic)
Routes a server-initiated subscribe through the user’s hooks.ws.subscribe authorization hook before the actual ws.subscribe runs. Returns Promise<string | null> - null on success, the denial reason as a string otherwise. Idempotent.
const denial = await platform.subscribe(ws, `room:${roomId}`);
if (denial) throw new LiveError(denial); platform.checkSubscribe(ws, topic)
Pure-gate companion - runs the authorization hook without modifying subscription state. Returns the same Promise<string | null> shape.
const denial = await platform.checkSubscribe(ws, topic);
if (denial) return; // do not read the buffer Upgrading from 0.4.x: Both methods are async since 0.5. Callers must
await. Pre-fix, asyncsubscribe/subscribeBatchhooks silently fell through to allow because the runtime comparedPromise<false>againstfalsesynchronously. See Migration 0.4 to 0.5.
Backpressure-aware primitives
platform.maxPayloadLength
Snapshot-stable getter. Returns the configured WebSocketOptions.maxPayloadLength (default 1 MB). Used by uploads for chunk-size auto-discovery.
platform.bufferedAmount(ws)
Constant-time pass-through to the underlying uWS connection’s outbound buffer. Returns 0 for closed connections. Pair with maxBackpressure to back off pacing when the queue saturates.
platform.publishBatched(messages)
Wire-level batched fan-out: one {type:'batch', events:[...]} frame per affected subscriber. Capability-gated - clients send {type:'hello', caps:['batch']} to opt in (the bundled client does this automatically). Falls back to a per-event loop for non-batch-capable clients.
platform.publishBatched([
{ topic: 'cursors', event: 'move', data: pos1, coalesceKey: 'user:1' },
{ topic: 'cursors', event: 'move', data: pos2, coalesceKey: 'user:1' }, // collapsed
{ topic: 'cursors', event: 'move', data: pos3, coalesceKey: 'user:2', seq: false }
]); Per-event options:
| Field | Description |
|---|---|
coalesceKey | Latest value at the latest position wins; same-key duplicates are dropped before framing. |
relay: false | Skip cross-worker relay for this event. |
seq: false | Suppress per-topic monotonic sequencing for this event. |
Frame-size budget. A batched frame larger than 256 KB triggers a throttled
console.warn; uWSpermessage-deflatemay kick in at large sizes and surprise CPU budgets. Chunk into multiplepublishBatchedcalls when the warning fires.
Capability fallback. When the server detects that any interested subscriber has not advertised the
'batch'capability, the call falls back to a per-eventpublish()loop so old clients receive plain envelopes. Mixing old and new clients in one call is safe.
Cross-worker relay. A single
publish-batchedIPC frame carries the full event list to other workers. Each receiving worker re-runs fast-path detection against its local subscriber set. Pass{relay: false}per-event when the messages came from an external pub/sub source already fanning out to every worker.
platform.sendCoalesced(ws, { key, topic, event, data })
Per-connection send with coalesce-by-key. Multiple sendCoalesced calls in the same flush-tick collapse to one frame per key; latest value wins, insertion order preserved. JSON.stringify is deferred to flush.
Server-initiated request/reply
platform.request(ws, event, data, options?)
Server -> client request that resolves with the client’s reply. Client side wires onRequest(handler) to handle inbound requests.
const reply = await platform.request(ws, 'confirm-action', { action: 'delete' }, { timeoutMs: 5000 }); | Option | Default | Description |
|---|---|---|
timeoutMs | 5000 | Rejects with Error('request timed out') past this. |
Pending requests tracked per-connection on userData[WS_PENDING_REQUESTS]. Past the per-connection cap, new requests reject synchronously. Connection close rejects all in-flight requests with Error('connection closed').
Pressure and rate signals
platform.pressure and platform.onPressure(cb)
Worker-local backpressure signal. Reasons (precedence: memory > publish-rate > subscribers):
type PressureReason = 'NONE' | 'PUBLISH_RATE' | 'SUBSCRIBERS' | 'MEMORY'; platform.onPressure(({ reason, ...details }) => {
if (reason !== 'NONE') metrics.gauge('ws_pressure', 1, { reason });
}); Thresholds configurable via WebSocketOptions.pressure. Each can be false to disable that source.
platform.onPublishRate(cb)
Per-topic publish-rate detection. Default response throttled console.warn; registering a callback suppresses the default. Thresholds: topicPublishRatePerSec (default 5000), topicPublishBytesPerSec (default 10 MB/s).
Request correlation
platform.requestId
UUID per HTTP / WS connection. Inbound X-Request-ID header overrides (sanitized: printable ASCII, max 128 chars). The adapter never auto-emits the header on responses.
Per-topic monotonic seq
Every broadcast envelope carries seq: N per topic. Pass { seq: false } to opt out. Worker-local in clustered mode (cluster-wide ordering needs an external sequencer).
Session resume
The adapter stamps a per-connection session id and announces it via {type:'welcome', sessionId}. The client stores it in sessionStorage and presents the prior id plus per-topic lastSeenSeqs in a {type:'resume'} frame on reconnect. Server replies {type:'resumed'} after the optional resume hook awaits.
// hooks.ws.js
export async function resume(ws, { sessionId, lastSeenSeqs, platform }) {
// Replay state, gap-fill, etc. The runtime awaits this before sending the resumed ack.
} The WS_SESSION_ID symbol slot is exported from files/utils.js for plugins that need to thread the session id through their own bookkeeping.
Was this page helpful?