What’s new in 0.5
0.5 is a substantial release. Most of the value is invisible (security defaults, capacity bounds, production assertions), but the surface-level additions cover real production needs that 0.4 left to hand-rolling.
If you are upgrading from 0.4, see Upgrade Quickstart for the 5-minute changelog. This page is the feature tour - everything that has landed across the 0.5 line so far.
Streaming uploads
live.upload() is a first-class streaming primitive. The handler consumes chunks via for await over ctx.stream; the client paces sends against the WebSocket buffered amount; the server auto-discovers the adapter’s maxPayloadLength and right-sizes chunks. Includes mid-stream re-auth via reauthEvery, abortable handles with progress events, and per-upload + aggregate buffer caps.
export const avatar = live.upload(async (ctx, name, mime) => {
const writer = await storage.createWriter(`avatars/${ctx.user.id}/${name}`, { mime });
for await (const chunk of ctx.stream) {
if (ctx.signal.aborted) break;
await writer.write(chunk);
}
await writer.close();
return { path: writer.path };
}, { maxSize: 25 * 1024 * 1024, reauthEvery: 16 * 1024 * 1024 }); In 0.4 you used live.binary and chunked uploads by hand against maxPayloadLength.
Cluster primitives
The extensions package added 11 cluster-grade primitives so you can run multi-instance production without hand-rolling distributed concerns:
createLeader- Redis-lease leader election. Plug intoconfigureCron({ leader })for one-cron-per-cluster instead of N-crons-per-N-workers.createDistributedLock- cluster-wide mutex withwithLock(key, fn, { maxWaitMs }). Same API shape as the in-process adapter lock.createDistributedSession- Redis-backed session store with sliding TTL.createConnectionRegistry- cluster-aware registry that backslive.push/live.notifyacross instances.createTaskRunner- durable Postgres-backed task runner with caller-retry idempotency, worker-crash recovery, and external-service idempotency.createJobQueue- PostgresFOR UPDATE SKIP LOCKEDqueue.createIdempotencyStore- three-state acquire (acquired/pending/result) for both Redis and Postgres.createShardedBus- SPUBLISH/SSUBSCRIBE sharded pub/sub (Redis 7+).createFunctionLibrary- Redis Functions wrapper (Redis 7+).createAdmissionControl- per-class message-tier admission rules.createPublishRateAggregator- cluster-wide top-publisher view.
In 0.4 these did not exist; running multi-instance meant hand-rolling each one.
Lifecycle hooks
hooks.ws.js now supports init({ platform }) and shutdown({ platform }) for once-per-worker setup and teardown:
export async function init({ platform }) {
setCronPlatform(platform);
live.configurePush({ remoteRegistry: registry });
await bus.activate(platform);
}
export async function shutdown() {
await leader.stop();
await bus.deactivate();
} start() awaits init before declaring the worker ready, which eliminates the boot-to-first-connect window where cron ticks fired into a no-op platform and live.push couldn’t reach cross-instance users.
In 0.4 you stuffed all of this into the open hook on first connect and hoped nothing depended on platform before someone opened a WebSocket.
Session resume protocol
Reconnects are no longer full restarts. The server stamps a per-connection sessionId and announces it via {type:'welcome', sessionId}. The client persists it in sessionStorage and presents the prior id plus per-topic lastSeenSeqs on reconnect; the server replies {type:'resumed'} after the optional resume hook awaits.
Pair with the replay extension’s resumeHook() for cluster-wide gap-free reconnection:
export const resume = replay.resumeHook(); In 0.4 reconnects refetched everything; clients saw flicker on busy boards.
Time-windowed aggregates
live.aggregate({ windows }) supports lifetime, tumbling, and sliding windows. IANA timezone boundaries via Intl.DateTimeFormat so DST and tz offsets behave correctly. Built-in combiners (combineSum, combineMax, combineMin, combineCounts, combineMerge) for sliding-window fold.
export const requestStats = live.aggregate('requests', reducers, {
topic: 'requests-stats',
windows: {
lifetime: { type: 'lifetime' },
today: { type: 'tumbling', period: 'daily', tz: 'America/New_York' },
last5min: { type: 'sliding', durationMs: 5 * 60_000, slideMs: 30_000 }
}
}); Six-field cron expressions
live.cron gained sub-minute granularity via an optional leading seconds field:
live.cron('*/10 * * * * *', 'tick', async () => { /* every 10 seconds */ }); Once any 6-field schedule registers, the cron tick adapts from 60s to 1Hz. 5-field schedules still fire only at second :00.
configureCron({ leader, bus }) (details) wires cluster-wide one-firing-per-tick via leader election and bus fan-out.
New platform surface
platform gained 12+ new methods covering server-initiated request/reply, authorized server-side subscribe, backpressure-aware primitives, and cluster-relay batching:
platform.subscribe(ws, topic)/platform.checkSubscribe(ws, topic)- server-initiated subscribe that routes through the user’ssubscribehook for authorization.platform.request(ws, event, data, opts?)- server-to-client request/reply with timeout.platform.publishBatched(messages)- one wire frame per affected subscriber, with optional per-eventcoalesceKeycollapsing.platform.sendCoalesced(ws, { key, topic, event, data })- per-connection send with coalesce-by-key.platform.pressure/platform.onPressure(cb)- worker-local backpressure signal with'MEMORY' | 'PUBLISH_RATE' | 'SUBSCRIBERS'precedence.platform.onPublishRate(cb)- per-topic publish-rate detection.platform.requestId- UUID per HTTP/WS connection for cross-layer log correlation.platform.maxPayloadLength/platform.bufferedAmount(ws)- backpressure-aware primitives for uploads and other paced senders.
Capacity model and production assertions
Every internal Map / Set has an explicit upper bound with documented saturation behavior. Constants are importable for tests and operator overrides:
import {
MAX_PRESENCE_REF,
MAX_PUSH_REGISTRY,
MAX_AGGREGATE_BUCKETS
} from 'svelte-realtime/server'; Production assertions track invariant violations as Prometheus counters (svelte_realtime_assertion_violations_total{category} and extensions_assertion_violations_total{category}). A non-zero rate is a framework bug, not an app bug.
See Architecture - Capacity model and Production Limits.
Security defaults
0.5 closes multiple latent fail-open bugs from 0.4 and tightens defaults across the board:
- Async access predicates correctly deny (was silently allowing every request).
live.idempotentcache key is namespaced by RPC path (closes cross-RPC cache replay).- Wire-level subscribes to
__-prefixed system topics rejected by default. ctx.publish('__*')throws (closes framework-internal-frame spoofing).- Wire-topic accept set restricted to printable ASCII (closes BiDi spoofing, zero-width chars).
/__ws/authPOST requiresx-requested-with/Sec-Fetch-Site/ matchingOrigin(CSRF defense).- Dynamic compression skipped for credentialed responses (BREACH defense).
- Refuse to start on
same-originpolicy without host pin. - Cookie
path/domainvalidated against same char class as values. - SSR dedup cache key includes
base_origin(closes virtual-hosting cross-tenant leak).
Bus envelope validation, replay subscribe-authorization, and Redis URL password redaction round out the extensions side.
Each default is opt-out for apps that need legacy behavior; see Adapter Configuration -> Security flags.
Adapter plugins
Three new bundled adapter plugins for in-process use:
createLock- per-key FIFO mutex with bounded wait.createSession- in-process session store with sliding TTL.createDedup- in-process dedup with fixed-window TTL.
Each has a matching cluster-wide variant in extensions (createDistributedLock, createDistributedSession, createIdempotencyStore). Same API shape; swap the import to switch scopes.
Wire format improvements
- Five-state connection status (
connecting/open/suspended/disconnected/failed) instead of three.suspendedcovers backgrounded tabs;failedis terminal. - Subscribe denial protocol. Each subscribe carries a numeric
ref; the server replies{type:'subscribed', topic, ref}or{type:'subscribe-denied', topic, ref, reason}. Reasons land on the client’sdenialsReadable. state/diffwire shape. Microtask-batched diffs replace the five-event format. Bundled clients handle transparently. (Originally landed aspresence_state/presence_diffin 0.5.0; renamed to barestate/diffin 0.5.7 to match the cursor / groups / replay convention of bare event names scoped to their plugin topic.)- Microtask-batched initial subscribes. N same-tick subscribes coalesce into one
subscribe-batchframe. - Per-topic monotonic
seqon every broadcast. Foundation for session resume.
TypeScript transport for SSR errors
realtimeTransport() from svelte-realtime/hooks registers serialization for RpcError and LiveError across the SvelteKit SSR / client boundary so typed errors arrive at +error.svelte with their class and code intact:
// src/hooks.js
import { realtimeTransport } from 'svelte-realtime/hooks';
export const transport = realtimeTransport(); Opt-in; if you do not catch errors by instanceof or err.code in your error boundary, you do not need it.
The realtime() factory
realtime({ bus, leader }) is a one-call setup that returns the standard adapter hook set and wires every framework publish surface in lockstep: RPC, cron, the reactive watcher path (live.effect, live.derived, live.aggregate, live.webhook), and the top-level publish() helper. Handler code in src/live/*.js is byte-identical between single-replica and cluster.
// src/hooks.ws.js
import { realtime } from 'svelte-realtime/server';
import { createRedis, createPubSubBus, createLeader } from 'svelte-adapter-uws-extensions/redis';
const redis = createRedis();
const bus = createPubSubBus(redis);
const leader = createLeader(redis);
export const { open, close, message, init } = realtime({ bus, leader: leader.isLeader });
export function upgrade({ cookies }) { return validateSession(cookies.session_id) || false; } Single-replica is realtime() with no config. Layer-1 primitives (setBus, getBus, getPlatform, top-level publish()) are first-class for fine-grained control - the factory is sugar over them.
In 0.4 you wired bus.activate(platform) in open and createMessage({ platform: (p) => bus.wrap(p) }) in message - the reactive seam silently missed cluster fan-out, and the per-callback wrap stacked into double-relays.
Cluster reaches the reactive seam
live.effect, live.derived, live.aggregate, and live.webhook handler publishes now relay through the cluster bus when one is configured.
Pre-0.5.6 these were the only framework publish surface that did NOT consult a bus - a leader-gated effect on replica A that published audit + notifications reached only the ~50% of users whose WS landed on replica A. The bug was invisible until a second replica spun up.
In 0.4 reactive handlers worked correctly only on single-replica; cross-replica fan-out had to be hand-rolled with explicit platform.publish calls that the user wrapped themselves.
Resume-grace by default
When the last subscriber of a stream unsubs, the stream releases its WebSocket subscription immediately but keeps the in-memory data model for resumeGraceMs (default 60 s). A new subscribe() inside the window re-attaches its listeners and sends the retained cursor on the resume envelope so the server gap-fills from its replay buffer.
configure({ resumeGraceMs: 0 }); // pre-grace behavior: every unsub is a full reset
configure({ resumeGraceMs: 5_000 }); // brief toggles
configure({ resumeGraceMs: 300_000 }); // navigation-heavy apps Pause/resume UIs ({#if active}<Sub />{/if}) and browser back/forward now feel instant - the events missed during the gap stream in via the replay buffer instead of refetching from scratch.
In 0.4 every unsub reset the data model; toggles re-loaded from cold and busy boards flickered on back/forward.
Volatile RPC
live.volatile(fn) marks a handler as fire-and-forget. The matching .fireAndForget(...args) client call returns void synchronously - no Promise, no dedup map, no timer, no devtools entry.
// src/lib/realtime/cursors.js
import { live } from 'svelte-realtime/server';
export const moveCursor = live.volatile(async (ctx, boardId, pos) => {
ctx.publish(`board:${boardId}`, 'cursor', pos);
}); <script>
import { moveCursor } from '$live/cursors';
function onPointerMove(e) {
moveCursor.fireAndForget(boardId, { x: e.clientX, y: e.clientY });
}
</script> For 60-120Hz hot paths (cursor, drag, typing indicators, telemetry beacons) this avoids 100K+ short-lived heap allocations per second on the client. Backpressure drops above volatileBackpressureBytes (default 4 MB), offline calls are silently dropped, and a new __devtools.volatile ring buffer tracks fire-and-forget sends.
In 0.4 every RPC paid full request/reply machinery even when the caller discarded the response.
ctx.skip(key, ms)
A per-key handler gate. Returns true if the key is in its cooldown window (caller should early-return), false if the call should run.
export const moveNote = live(async (ctx, noteId, x, y) => {
if (ctx.skip(`move:${noteId}`, 16)) return; // drop calls within 16ms per note
await dbUpdateNote(noteId, x, y);
ctx.publish('notes', 'updated', { noteId, x, y });
}); Pairs with ctx.shed(...) so call sites read uniformly with an early return. State is per-replica - for cross-replica gating use live.rateLimit({ store: 'redis' }).
In 0.4 you wrote your own lastSeenAt[key] map and the Date.now() check by hand at the top of every handler that needed gating.
ctx.publishThrottled / ctx.publishDebounced
The publish helpers were renamed for clarity. “throttle” / “debounce” in JS-land typically mean “gate a function’s execution” - and developers kept misreading ctx.throttle('move:id', 50) as “gate this handler” when it actually scheduled an outbound publish. The new names put publish central.
// New (canonical)
ctx.publishThrottled('cursors', 'update', position, 50);
ctx.publishDebounced('search:' + id, 'set', { query }, 300); The old ctx.throttle / ctx.debounce keep working as soft-deprecated aliases with a one-time dev warn per name. For the gate-the-handler use case the old names looked like, use ctx.skip(key, ms) (above).
onJsonMessage plugin dispatch
createMessage({ onJsonMessage(ws, msg, platform) }) receives the already-parsed envelope from svelte-adapter-uws@^0.5.3 so plugins like cursor.hooks.message no longer pay a second TextDecoder + JSON.parse per frame.
export const message = createMessage({
onJsonMessage(ws, msg, platform) {
if (msg.type === 'cursor') cursor.hooks.message(ws, { data: msg, platform });
}
}); In 0.4 you wired plugins through onUnhandled and re-parsed the bytes manually on every frame - ~7x slower at cursor scale (1000 movers x 60 Hz).
Cursor plugin: wire-format split
The cursor plugin now uses two channels: catalog / join for roster (user metadata) and update / bulk / remove for positions. User metadata flows once per (ws, topic) when a user first moves, instead of riding every position frame. Per-frame bytes drop from ~100 to ~16 per cursor at peak.
topicThrottle (default 16 ms / 60 Hz) coalesces all dirty movers on a topic into a single frame per window so bandwidth per peer scales with active-mover count, not movers x rate. The per-cursor throttle default also moves from 50 ms to 16 ms for symmetry.
In 0.4 cursor frames carried full user metadata on every move and broadcast one frame per cursor per cycle - 1000-mover rooms melted wire bandwidth.
Client-side move() cursor helper
<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> move(topic, data) coalesces sends via requestAnimationFrame so a 1000 Hz high-DPI mouse collapses to at most one send per repaint. SSR-safe; multi-topic callers do not clobber each other.
In 0.4 you wrote your own requestAnimationFrame-throttle on the client or paid the wire cost of every mousemove.
Presence: state / diff and self-healing
Wire events on __presence:{topic} renamed to bare state / diff (was presence_state / presence_diff) to match cursor / groups / replay convention. The bundled presence() Svelte client store is updated in lockstep - only hand-rolled wire decoders need to change.
Default presence(topic) calls now self-heal within 90 s of ungraceful disconnects with no per-app config: server heartbeat defaults to 30 s, client maxAge defaults to 90 s, and the heartbeat carries a {userKey: data} map so swept-out entries re-appear from the next tick without waiting for a diff.
Per-board presence now self-heals across reconnects too: the client sends {type:'presence-snapshot', topic} on every status === 'open', symmetric to the existing cursor-snapshot path.
In 0.4 the default presence('room') left ghost “X here” badges after every ungraceful disconnect until full page reload, and per-board presence missed any presence_diff that fired during a network blip.
Closed-WS hardening
platform.subscribe, unsubscribe, send, sendCoalesced, sendTo, and request now swallow uWS’s Invalid access of closed uWS.WebSocket throw when the underlying socket has been freed (typical post-await on a mass-connect race) and bump a new platform.closedWsAborts counter. Plugins throw WsClosedError (code: 'WS_CLOSED') instead of silently returning, giving operators visibility into mid-async-gap closures.
try {
await presence.join(ws, topic, platform);
} catch (err) {
if (err.code !== 'WS_CLOSED') throw err;
// client disconnected mid-join; state already rolled back
} In 0.4 a client closing mid-join was silently lost - the RPC reported success, the presence count under-counted, and operators saw a healthy status="ok" rate while ~10-15% of mass-connects never actually landed.
Smaller additions
MAX_PRESENCE_REF/MAX_PUSH_REGISTRYTS type exports forsvelte-realtime/server(Capacity model).batch()of 1 sends a bare RPC frame - defensivebatch(() => [oneCall()])symmetry now pays no envelope cost.- Cursor scheduler
stats()accessor for spotting event-loop saturation:{ flushes, driftMeanMs, driftMaxMs, dirtyTopicsCurrent, activeTopicsTotal }. - uWS default doc correction: uWS’s own
maxPayloadLengthdefault is 16 KB, not 16 MB. The adapter’s 1 MB default still ships, just with accurate framing.
Where to go next
- Quick Start - new project in 30 seconds.
- Manual Setup - add 0.5 to an existing SvelteKit project.
- Upgrade Quickstart - 0.4 -> 0.5 in 5 minutes.
- Migration 0.4 to 0.5 - full upgrade guide.
- Distributed Pub/Sub - the
realtime({ bus, leader })cluster setup. - Architecture - the technical tour, top to bottom.
Was this page helpful?