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

VariableDefaultDescription
PORT3000Server port
HOST0.0.0.0Bind address
ORIGIN(derived)Public URL (e.g. https://myapp.com)
SSL_CERT-Path to TLS certificate
SSL_KEY-Path to TLS private key
SHUTDOWN_TIMEOUT30Graceful 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.

ApproachWhen to use
CLUSTER_WORKERSSingle-machine, no Docker/k8s managing processes
Docker replicasProduction with infrastructure managing processes + external pub/sub

Cross-worker safety

MethodCross-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.connectionsNo (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

  • ORIGIN env set to canonical public URL, or HOST_HEADER set to a trusted proxy header, or native TLS configured (SSL_CERT + SSL_KEY), or explicit allowedOrigins: ['https://...'] array. Without one of these, the runtime refuses to start when allowedOrigins: '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_KEY paths point at valid PEMs readable by the process.

Auth and CSRF

  • hooks.ws.js exports unsubscribe alongside message and close. Missing exports leak presence and replay state. (0.5 requirement.)
  • upgrade() hook validates session strictly; never falls through to anonymous.
  • If you use the authenticate hook for cookie refresh: native (non-browser) clients hitting /__ws/auth stamp x-requested-with: XMLHttpRequest. Bundled browser client handles automatically.
  • No upgradeResponse() call attaches Set-Cookie to the 101 response. Use the authenticate hook instead. (Breaks silently behind Cloudflare Tunnel.)

Security flags

  • Audit async subscribe / subscribeBatch hooks and async access / filter / live.gate predicates. 0.5 correctly awaits these; predicates that silently allowed in 0.4 will now correctly deny.
  • websocket.allowSystemTopicSubscribe left at default false unless you legitimately route public topics through the __ prefix.
  • websocket.authPathRequireOrigin left at default true unless native clients need the endpoint without browser-style headers.
  • websocket.compressCredentialedResponses left at default false unless 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) and live.configurePush({ remoteRegistry }) called from init({ platform }), not from open(). 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.cron in a clustered deployment: configureCron({ leader, bus }) with a Redis-backed createLeader. Without leader election, N workers each fire every tick.

Resources and capacity

  • ulimit -n raised 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_LIMIT set (512K, 2M, etc.) per app needs. Negative or non-finite values now throw at startup.
  • websocket.upgradeAdmission.maxConcurrent set per worker if you have a hard headroom budget. Beyond the cap clients see HTTP 503.
  • websocket.pressure thresholds 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: createAdmissionControl per RPC class (background / critical / etc.). See Production Limits and Capacity.
  • Plugin maxBuckets / maxTopics / maxConnections / maxKeys reviewed if defaults (1M) are too generous for your scale; usually they are fine.

Clustering and replicas

  • CLUSTER_WORKERS=auto or 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:shutdown event handler closes DB pools, Redis subscribers, etc.
  • If using CLUSTER_MODE=reuseport, confirm Linux only. macOS/Windows fall back to acceptor mode.

Extensions (if used)

  • Redis client uses the bundled createRedisClient (not raw new Redis()) so the shared circuit breaker is wired.
  • Postgres tables: svti_* or explicit table overrides if migrating from ws_*. See Postgres extension migration.
  • presence.hooks destructure includes unsubscribe if you use Redis presence: export const { subscribe, unsubscribe, close } = presence.hooks;.
  • If using createShardedBus or createFunctionLibrary: Redis version is 7+. Older Redis throws on activate.
  • If using createReplay({ durability: 'replicated' }): Redis WAIT count and timeout sized for your fleet.
  • bus.activate(platform) called from init({ platform }), not open().

Observability

  • createMetrics() instance wired into live.metrics(metrics), wirePublishRateMetrics, connectionMetricsHook, and every extension that accepts a metrics option.
  • /metrics endpoint mounted (default app.get('/metrics', metrics.handler)); scrape target configured in Prometheus.
  • mapTopic configured 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_total and lock_lost_total (saturation on distributed lock)
    • cron{status='leader-error'} and cron{status='skipped'}
    • Memory and per-topic publish-rate gauges crossing your pressure thresholds.

Final smoke

  • npm run build succeeds cleanly.
  • node build starts and listens on the configured port; logs show WebSocket endpoint registered at /ws and Listening 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

LimitDefaultNotes
maxPayloadLength16 KBRPC requests exceeding this close the connection silently. Increase in adapter websocket config for large payloads
maxBackpressure1 MBMessages silently dropped when send buffer exceeds this
sendQueue cap1000Client-side offline queue drops oldest when exceeded
batch() size50Client 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?