Deployment
Build
npm run build
node build That’s it. The adapter bundles everything into a standalone Node.js server - your SvelteKit app, WebSocket handler, and all $live/ modules in one process.
Docker
uWebSockets.js is a native C++ addon, so your Docker image needs glibc >= 2.38. 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"] Important: Use Debian Trixie or Ubuntu 24.04+ based images. Bookworm-based images (
node:*-slim,node:*-bookworm) ship glibc 2.36 which is too old. Don’t use Alpine - uWebSockets.js requires glibc, not musl.
Environment variables
| Variable | Default | Description |
|---|---|---|
PORT | 3000 | Server port |
HOST | 0.0.0.0 | Bind address |
ORIGIN | (derived) | Public URL (e.g. https://myapp.com) |
SSL_CERT | - | Path to TLS certificate |
SSL_KEY | - | Path to TLS private key |
SHUTDOWN_TIMEOUT | 30 | Graceful shutdown wait in seconds |
CLUSTER_WORKERS | - | Worker threads (auto for CPU count) |
# Behind nginx
ORIGIN=https://myapp.com PORT=8080 node build
# Native TLS
SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 node build
# Multi-core
CLUSTER_WORKERS=auto node build TLS
svelte-adapter-uws handles TLS natively via uWebSockets.js SSLApp. No Nginx or Caddy needed:
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.
Clustering
The adapter supports multi-core scaling. Each worker handles connections independently, and platform.publish() is automatically relayed across all workers.
CLUSTER_WORKERS=auto node build Two modes (auto-detected):
- reuseport (Linux) - each worker binds to the same port via
SO_REUSEPORT. No single-threaded bottleneck. - acceptor (macOS/Windows) - a primary thread distributes connections to workers.
Docker replicas vs CLUSTER_WORKERS
If you have external pub/sub (Redis, Postgres LISTEN/NOTIFY) handling cross-process messaging, you don’t need CLUSTER_WORKERS. Just run multiple replicas:
# 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 On Linux, SO_REUSEPORT lets multiple processes bind to the same port. The kernel distributes connections across them.
| Approach | When to use |
|---|---|
CLUSTER_WORKERS | Single-machine, no Docker/k8s managing processes |
| Docker replicas | Production with infrastructure managing processes + external pub/sub |
Cross-worker safety
| Method | Cross-worker? | Safe in live()? |
|---|---|---|
ctx.publish() | Yes (relayed) | Yes |
ctx.platform.send() | N/A (single ws) | Yes |
ctx.platform.sendTo() | No (local only) | Use with caution |
ctx.platform.subscribers() | No (local only) | Use with caution |
ctx.platform.connections | No (local only) | Use with caution |
ctx.publish() is always safe - it relays across workers and, with Redis wrapping, across instances. For targeted messaging, prefer publish() with a user-specific topic over sendTo().
OS tuning
Linux defaults are conservative. For deployments expecting more than a few hundred concurrent WebSocket connections, apply these settings.
Kernel parameters
Add to /etc/sysctl.conf and run sysctl -p:
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.tcp_tw_reuse = 1
net.core.somaxconn = 4096
fs.file-max = 1024000
net.netfilter.nf_conntrack_max = 262144
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_defer_accept = 5 - TCP Fast Open (
tcp_fastopen = 3) - saves 1 RTT on reconnecting clients - TCP Defer Accept (
tcp_defer_accept = 5) - ignores port scanners and half-open probes at the kernel level
File descriptor limits
Each WebSocket connection uses one file descriptor. The default limit (1024) caps you at roughly 1000 concurrent connections regardless of CPU or memory.
Add to /etc/security/limits.conf:
* soft nofile 1024000
* hard nofile 1024000
root soft nofile 1024000
root hard nofile 1024000 The * wildcard doesn’t apply to root on most distributions. If the app runs as root (common in Docker), the explicit root lines are required.
Docker ulimits
services:
app:
ulimits:
nofile:
soft: 65536
hard: 65536 Without this, each container is limited to 1024 file descriptors.
Connection management
uWebSockets.js manages connections at the C++ level:
- HTTP keepalive - idle connections close after 10 seconds (compiled into C++, not configurable)
- Slow-loris protection - connections slower than 16 KB/second are dropped before reaching your code
- WebSocket ping/pong - automatic with
idleTimeout(default 120 seconds). The client store handles pong automatically
Stress testing
Don’t stress test from your local machine against a remote server - your home router’s NAT table (1024-4096 entries) will fill up, dropping ALL new connections including SSH.
Symptoms: connection ceiling stuck around 1200-1900, SSH times out, other devices lose internet, server CPU barely loaded.
Run stress tests from the server itself (localhost to localhost) or from a machine on the same network.
Production checklist
Work top-to-bottom before flipping DNS to the new instance.
Origin / TLS / host pinning
-
ORIGINenv set to canonical public URL, orHOST_HEADERset to a trusted proxy header, or native TLS configured (SSL_CERT+SSL_KEY), or explicitallowedOrigins: ['https://...']array. Without one of these, the runtime refuses to start whenallowedOrigins: 'same-origin'(the default) is in effect - this closes a real attack class, not a paranoid check. See Adapter -> refuse-to-start if you hit it. - If running behind a TLS-terminating proxy:
PROTOCOL_HEADER=x-forwarded-proto+HOST_HEADER=x-forwarded-host+ADDRESS_HEADER=x-forwarded-for. Trust only headers the proxy controls. - If running native TLS:
SSL_CERT+SSL_KEYpaths point at valid PEMs readable by the process.
Auth and CSRF
-
hooks.ws.jsexportsunsubscribealongsidemessageandclose. Missing exports leak presence and replay state. (0.5 requirement.) -
upgrade()hook validates session strictly; never falls through to anonymous. - If you use the
authenticatehook for cookie refresh: native (non-browser) clients hitting/__ws/authstampx-requested-with: XMLHttpRequest. Bundled browser client handles automatically. - No
upgradeResponse()call attachesSet-Cookieto the 101 response. Use theauthenticatehook instead. (Breaks silently behind Cloudflare Tunnel.)
Security flags
- Audit async
subscribe/subscribeBatchhooks and asyncaccess/filter/live.gatepredicates. 0.5 correctly awaits these; predicates that silently allowed in 0.4 will now correctly deny. -
websocket.allowSystemTopicSubscribeleft at defaultfalseunless you legitimately route public topics through the__prefix. -
websocket.authPathRequireOriginleft at defaulttrueunless native clients need the endpoint without browser-style headers. -
websocket.compressCredentialedResponsesleft at defaultfalseunless you have audited reflected-input surface for BREACH. - If running clustered, consider
websocket.workerRelayHmacSecret(>= 16 chars, same value across all workers) for cross-worker tamper defense.
Lifecycle and platform capture
-
setCronPlatform(platform)andlive.configurePush({ remoteRegistry })called frominit({ platform }), not fromopen(). Eliminates the boot-to-first-connect window where cron fires no-op. See Lifecycle Hooks. -
shutdown({ platform })exported if you have cluster primitives that benefit from clean teardown (leader.stop(),registry.destroy(), etc.). - If using
live.cronin a clustered deployment:configureCron({ leader, bus })with a Redis-backedcreateLeader. Without leader election, N workers each fire every tick.
Resources and capacity
-
ulimit -nraised to 65536+ on every host (containers and bare metal). Default 1024 is the silent connection ceiling everyone hits first. - Kernel parameters tuned if expecting >1000 concurrent connections (
net.core.somaxconn,net.ipv4.tcp_fin_timeout, see Kernel parameters). -
BODY_SIZE_LIMITset (512K,2M, etc.) per app needs. Negative or non-finite values now throw at startup. -
websocket.upgradeAdmission.maxConcurrentset per worker if you have a hard headroom budget. Beyond the cap clients see HTTP 503. -
websocket.pressurethresholds set or explicitly disabled. Default thresholds suit most apps; turn on if you have a load-shedding story. - If you use a message-tier admission control:
createAdmissionControlper RPC class (background / critical / etc.). See Production Limits and Capacity. - Plugin
maxBuckets/maxTopics/maxConnections/maxKeysreviewed if defaults (1M) are too generous for your scale; usually they are fine.
Clustering and replicas
-
CLUSTER_WORKERS=autoor Docker replicas configured. Single-process is fine for low scale; multi-instance needs Redis pub/sub bus. -
restart: unless-stopped(or your orchestrator equivalent) so the process recovers from crashes. - Graceful shutdown:
sveltekit:shutdownevent handler closes DB pools, Redis subscribers, etc. - If using
CLUSTER_MODE=reuseport, confirm Linux only. macOS/Windows fall back toacceptormode.
Extensions (if used)
- Redis client uses the bundled
createRedisClient(not rawnew Redis()) so the shared circuit breaker is wired. - Postgres tables:
svti_*or explicittableoverrides if migrating fromws_*. See Postgres extension migration. -
presence.hooksdestructure includesunsubscribeif you use Redis presence:export const { subscribe, unsubscribe, close } = presence.hooks;. - If using
createShardedBusorcreateFunctionLibrary: Redis version is 7+. Older Redis throws on activate. - If using
createReplay({ durability: 'replicated' }): RedisWAITcount and timeout sized for your fleet. -
bus.activate(platform)called frominit({ platform }), notopen().
Observability
-
createMetrics()instance wired intolive.metrics(metrics),wirePublishRateMetrics,connectionMetricsHook, and every extension that accepts ametricsoption. -
/metricsendpoint mounted (defaultapp.get('/metrics', metrics.handler)); scrape target configured in Prometheus. -
mapTopicconfigured if your topics are user-generated (room IDs, user IDs, etc.); without it, per-topic labels grow unbounded. See Cardinality control. - Alerts on:
pubsub_parse_errors_total/sharded_pubsub_parse_errors_total/notify_parse_errors_total(rate > 0 means malformed envelopes hitting the bus)assertion_violations_total{category}(rate > 0 means a framework bug)pubsub_degraded_total/pubsub_recovered_total(transitions on the shared circuit breaker)lock_acquire_timeouts_totalandlock_lost_total(saturation on distributed lock)cron{status='leader-error'}andcron{status='skipped'}- Memory and per-topic publish-rate gauges crossing your
pressurethresholds.
Final smoke
-
npm run buildsucceeds cleanly. -
node buildstarts and listens on the configured port; logs showWebSocket endpoint registered at /wsandListening on .... - Open two browser tabs at the live URL; click; both update. (The hello-realtime smoke test.)
- Auth-gated routes deny correctly for anonymous users.
- If multi-instance: a publish on instance A reaches a subscriber on instance B.
- Graceful shutdown signal (
SIGTERM) drains in-flight requests before exit.
For the full adapter-level reference, see Adapter Deployment and Production Limits and Capacity.
Redis multi-instance
Use createMessage with the Redis pub/sub bus for multi-instance deployments. ctx.publish automatically goes through Redis when the platform is wrapped.
// 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;
} realtime({ bus, leader }) (added in 0.5.6) wires bus + leader + platform from a single declaration of cluster intent. The bus reaches every framework publish surface in lockstep: RPC ctx.publish, cron tick, the reactive watcher path (live.effect, live.derived, live.aggregate, live.webhook), and the top-level publish() helper. No changes needed in your live modules - handler code is byte-identical between single-replica and cluster.
Combined: Redis + rate limiting
realtime() returns the standard hook set; for the cross-cutting beforeExecute rate-limit gate, swap realtime()’s message for a createMessage you composed yourself. The bus is still wired once via setBus, so the reactive seam and cron tick stay cluster-correct without a per-hook bus callback:
import { realtime, createMessage, setBus, LiveError } from 'svelte-realtime/server';
import { createRedis, createPubSubBus, createLeader, createRateLimit } from 'svelte-adapter-uws-extensions/redis';
const redis = createRedis();
const bus = createPubSubBus(redis);
const leader = createLeader(redis);
const limiter = createRateLimit(redis, { points: 30, interval: 10000 });
setBus(bus);
export const { open, close, init } = realtime({ leader: leader.isLeader });
export function upgrade({ cookies }) { return validateSession(cookies.session_id) || false; }
export const message = createMessage({
async beforeExecute(ws, rpcPath) {
const { allowed, resetMs } = await limiter.consume(ws);
if (!allowed)
throw new LiveError('RATE_LIMITED', `Retry in ${Math.ceil(resetMs / 1000)}s`);
}
}); Without a platform callback, createMessage auto-wraps with whatever setBus(...) wired - one source of truth, no double-wrap.
Postgres NOTIFY
Combine live.stream with the Postgres NOTIFY bridge for zero-code reactivity. A database trigger fires pg_notify(), the bridge calls platform.publish(), and the stream auto-updates.
// src/hooks.ws.js
export { message } from 'svelte-realtime/server';
import { createPgClient, createNotifyBridge } from 'svelte-adapter-uws-extensions/postgres';
const pg = createPgClient({ connectionString: process.env.DATABASE_URL });
const notify = createNotifyBridge(pg, {
channel: 'table_changes',
parse: (payload) => JSON.parse(payload)
});
export function open(ws, { platform }) {
notify.activate(platform);
} // src/live/orders.js - no ctx.publish needed, the DB trigger handles it
export const createOrder = live(async (ctx, items) => {
return db.orders.insert({ userId: ctx.user.id, items });
});
export const orders = live.stream('orders', async (ctx) => {
return db.orders.forUser(ctx.user.id);
}, { merge: 'crud', key: 'id' }); Limits and gotchas
| Limit | Default | Notes |
|---|---|---|
maxPayloadLength | 16 KB | RPC requests exceeding this close the connection silently. Increase in adapter websocket config for large payloads |
maxBackpressure | 1 MB | Messages silently dropped when send buffer exceeds this |
sendQueue cap | 1000 | Client-side offline queue drops oldest when exceeded |
batch() size | 50 | Client rejects before sending if exceeded. Server enforces same limit |
ws.subscribe() vs the subscribe hook
live.stream() calls ws.subscribe(topic) server-side, bypassing the adapter’s subscribe hook entirely. Stream topics are gated by guard(), not the subscribe hook.
Was this page helpful?