Architecture

This page covers the full technical stack from build tooling to distributed deployments. It is designed to be read top-to-bottom as a reference.


Build Pipeline

svelte-realtime ships a Vite plugin (svelte-realtime/vite) that runs at build time. It scans src/live/*.ts files, extracts exported function signatures, and generates typed client stubs importable as $live/moduleName. The result is full end-to-end type safety with zero manual type definitions - the server module is the single source of truth.

The generated stubs are virtual modules. They never exist on disk. Vite resolves them in dev and production identically, so there is no behavioral gap between environments.

src/live/chat.ts          Vite plugin scans           $live/chat
  export function         ----------------->   { send, messages$ }
  send(msg: string)       extracts signatures    ^-- typed client stub
  export const            generates stubs             (virtual module)
  messages$ = stream()

Server Runtime

uWebSockets.js

The server adapter (svelte-adapter-uws) replaces Node’s built-in HTTP stack with uWebSockets.js, a C++ HTTP and WebSocket server exposed to Node via N-API bindings. This is not a wrapper around ws or socket.io - it is a direct binding to uSockets, the same library that powers production WebSocket infrastructure at scale.

Single-threaded throughput on commodity hardware:

  • 50,000+ concurrent WebSocket connections per process
  • Sub-millisecond message relay latency
  • Under 100 MB RSS at 10,000 connections

Worker Thread Clustering

For multi-core utilization, the adapter supports SO_REUSEPORT clustering. Multiple Node processes bind to the same port and the kernel distributes connections across them. Each process is fully independent - no shared memory, no IPC for message routing. Cross-process communication goes through Redis when needed.

            SO_REUSEPORT
Port 3000 ----+----> Process 1 (adapter-uws)
              |----> Process 2 (adapter-uws)
              +----> Process 3 (adapter-uws)

WebSocket Lifecycle

All WebSocket events flow through src/hooks.ws.ts:

HookFires whenTypical use
upgradeHTTP upgrade request arrivesSet user identity from cookies or tokens
openWebSocket connection establishedJoin presence, activate plugins
messageClient sends a frameHandled automatically by the framework
subscribeClient subscribes to a topicAuthorization checks, replay delivery
unsubscribeClient leaves a topicCleanup
closeConnection dropsRemove from presence, cleanup state

Protocol

svelte-realtime uses a JSON protocol over WebSocket. Every frame is a single JSON object with a type field. The protocol handles RPC calls, stream subscriptions, topic pub/sub, presence, and control messages.

RPC calls are request-response: the client sends { type: "rpc", rpc: "moduleName.fn", args: [...] } and receives { ok: result } or { error: { code, message } }. Streams are server-push: after subscription, the server sends { topic, event: "update", data } whenever state changes.

Message ordering is strict per-topic. In distributed mode, atomic Lua scripts in Redis enforce sequence numbers on replay buffers so clients always receive messages in the correct order, even after reconnection.


Plugin System

The adapter has a built-in plugin architecture. Plugins are middleware that hook into the WebSocket lifecycle. All plugins ship with the adapter - there is nothing extra to install for single-instance deployments.

PluginWhat it does
ReplayBuffers recent messages per topic, delivers on reconnect
PresenceTracks who is connected to each topic, with join/leave events
Rate LimitingToken bucket per user, rejects excess messages
Throttle / DebounceServer-side message coalescing
CursorEphemeral position tracking (mouse, selection, drawing)
Broadcast GroupsNamed groups with membership and roles
Typed ChannelsSchema-validated topic messages
QueueOrdered processing with backpressure
MiddlewareRequest/response interceptors

Distributed Architecture

When a single instance is not enough, the extensions package (svelte-adapter-uws-extensions) provides drop-in replacements backed by Redis and Postgres. Same API, distributed state.

Redis Extensions

Distributed pub/sub - platform.publish() fans out through Redis so messages reach subscribers on all instances, not just the local process.

Persistent replay buffers - Messages are stored in Redis sorted sets with sequence numbers. When a client reconnects and sends its last sequence number, the buffer replays everything since then - even across server restarts. Ordering is enforced by atomic Lua scripts that assign sequence numbers inside Redis, so concurrent publishes from multiple instances never produce gaps or reordering.

Cross-instance presence - Presence state is stored in Redis hashes with per-entry TTLs. Each instance heartbeats its local connections. If an instance crashes without sending leave events, the TTL sweep in Redis automatically expires stale entries. Multi-tab dedup is handled at the Redis level - the same user in three browser tabs shows up once in the presence set.

Distributed rate limiting - Token bucket algorithm implemented as an atomic Lua script in Redis. Rate limits are enforced across all instances, so a user cannot exceed their quota by hitting different servers.

Postgres Extensions

LISTEN/NOTIFY bridge - Postgres change notifications are forwarded directly to WebSocket clients. When a row changes, a trigger fires pg_notify, the bridge picks it up, and publishes it to the relevant WebSocket topic.

Replay buffer (Postgres) - Alternative to Redis replay for teams that prefer a single datastore. Messages are stored in a Postgres table with the same replay semantics.

Circuit Breaker

All distributed extensions share a circuit breaker. When Redis or Postgres becomes unreachable:

  1. The breaker trips after a configurable number of failures
  2. All extensions fail fast instead of queueing timeouts
  3. Fire-and-forget operations (heartbeats, cursor broadcasts) are silently skipped
  4. A probe request is sent periodically to check if the backend is back
  5. When the probe succeeds, the breaker resets and normal operation resumes

Redis is not a single point of failure. When the circuit breaker trips, the system degrades gracefully to local-only operation - in-memory pub/sub, local presence, local rate limits. Clients stay connected and the application keeps working. When Redis comes back, distributed state resynchronizes automatically.

                +-- Instance 1 --+
Clients <--->   |  adapter-uws   |  <---+---> Redis
                +----------------+      |    (pub/sub, presence,
                                        |     replay, rate limit)
                +-- Instance 2 --+      |
Clients <--->   |  adapter-uws   |  <---+     Circuit breaker:
                +----------------+      |     Redis down? fail fast,
                                        |     fall back to local state
                +-- Instance N --+      |
Clients <--->   |  adapter-uws   |  <---+
                +----------------+
                                        |
                                        v
                                    Postgres
                                   (persistent data,
                                    NOTIFY bridge)

Failure Modes

Redis goes down

All Redis extensions accept an optional circuit breaker. The breaker trips after a configurable number of consecutive failures (default 5). Once broken, cross-instance pub/sub, presence writes, replay buffering, and distributed rate limiting are skipped entirely - no retries, no queuing, no thundering herd. Local delivery continues normally: ctx.publish() still reaches subscribers on the same instance and across workers. After a configurable timeout (default 30s), the breaker enters a probing state where a single request is allowed through. If it succeeds, the breaker resets to healthy and all extensions resume.

Instance crashes mid-session

The distributed presence extension runs a heartbeat cycle (default 30s) that probes each tracked WebSocket with getBufferedAmount(). Under mass disconnect, the runtime may drop close events entirely - the heartbeat catches these and triggers a synchronous leave. On the Redis side, stale presence entries are cleaned by a server-side Lua script that scans the hash and removes fields older than the configurable TTL (default 90s). The LEAVE_SCRIPT atomically checks whether the same user is still connected on another instance before broadcasting a leave event, so users don’t appear to leave and rejoin when a single instance restarts.

Client reconnects after a long disconnect

Reconnection uses up to three tiers depending on what’s available and how large the gap is. The replay buffer (configurable, default 1000 messages per topic) fills small gaps with strict per-topic ordering via atomic Lua sequence numbering. If the gap is too large for replay, delta sync kicks in - the client sends its last known version, and the server returns only the changes since that version (or {unchanged: true} if nothing changed). If neither replay nor delta sync can cover the gap, the client falls back to a full refetch of the init function. All three paths are automatic and require no client-side code changes.

Send buffer overflow

Each WebSocket connection has a send buffer limit (default 1MB) controlled by maxBackpressure in the adapter config. When the buffer is full, messages are silently dropped. In dev mode, handleRpc logs a warning when a response fails to deliver.

The default is conservative. Raise it if your workload needs it:

adapter({
  websocket: {
    maxBackpressure: 4 * 1024 * 1024 // 4MB
  }
})

For streams that produce high-frequency output (cursors, live charts, sensor data), use ctx.publishThrottled / ctx.publishDebounced to control the publish rate server-side rather than increasing the buffer indefinitely. The same applies to maxPayloadLength (default 1 MB) - it’s a single config line to raise if your RPCs send larger payloads.

Closed-WS during async setup

Mass-connect workloads regularly reach the post-await branch of a subscribe / subscribeBatch / presence.join / cursor.attach call with a freed native ws handle - clients close mid-setup at ~10-15% of connect rate under load. Since adapter 0.5.5, every public platform.* method that takes a ws argument swallows uWS’s Invalid access of closed uWS.WebSocket exception, returns a success-shaped no-op sentinel, and bumps platform.closedWsAborts. Plugins (presence.join, cursor.attach) throw WsClosedError (code: 'WS_CLOSED') so operators see the abort in metrics rather than a misleading status="ok" rate.


Production Limits

These are enforced server-side. Most limits respond with a specific error code. The exceptions are backpressure (messages are silently dropped when the send buffer is full, configurable via maxBackpressure) and the client send queue (oldest item dropped when the offline queue exceeds 1000 messages).

LimitDefaultOn breach
Max message size1 MBConnection closed (uWS behavior)
Max backpressure1 MB per connectionMessages silently dropped
Upgrade rate limit10 per 10s per IPHTTP 429
Upgrade admission maxConcurrentunset (opt-in)HTTP 503
Upgrade admission perTickBudgetunset (opt-in)Paced via setImmediate
Batch size50 RPC callsClient rejects before sending
Client send queue1000 messagesOldest item dropped
Presence refs (MAX_PRESENCE_REF)1,000,000 entriesPending-leave entries evicted first, then new joins dropped with one-shot warn
Rate-limit identities (_RATE_LIMIT_MAX)5,000 bucketsStale swept, then new identities rejected with RATE_LIMITED
Throttle/debounce timers (_THROTTLE_DEBOUNCE_MAX)5,000 entriesNew entries bypass timer, publish immediately (never silently dropped)
Topic length256 charactersRejected if longer or contains control characters
Replay buffer depth1000 per topicOldest messages evicted
Upload aggregate pre-handler buffer64 MBOVERLOADED
Upload maxSize100 MBAborts with OVERLOADED
Upload maxConcurrentPerSession4New uploads reject synchronously with OVERLOADED
Aggregate sliding-window buckets1000Module-load validation refuses to register

All limits are configurable per-deployment.

Capacity model

Every internal Map / Set whose growth is driven by client behavior or topic cardinality has an explicit upper bound and a documented saturation behavior. The bounds are exported as named constants from svelte-realtime/server so they can be referenced or overridden in tests:

import {
  MAX_PRESENCE_REF,
  MAX_PUSH_REGISTRY,
  MAX_OPTIMISTIC_QUEUE_DEPTH,
  MAX_AGGREGATE_BUCKETS,
  TOPIC_WS_COUNTS_WARN_THRESHOLD,
  SILENT_TOPIC_WARN_DEDUP_MAX,
  PUBLISH_RATE_WARN_DEDUP_MAX
} from 'svelte-realtime/server';

Saturation behavior across primitives is one of: REJECT (cap rejects new entries), WARN-ONLY (logs once when crossed), FIFO-evict (oldest entry dropped to make room), or WARN-then-skip (warns once then skips the operation). Pick a primitive’s failure mode based on whether dropping silently is acceptable for your workload.

MAX_PRESENCE_REF (1,000,000)

In-memory map of ${topic}\0${userId} -> { count, timer, data } populated by live.room’s data-stream onSubscribe and drained on the 5-second grace timer set by onUnsubscribe. Two distinct roles:

  1. Refcounts joins per (user, room) pair so multiple WebSocket subscriptions from the same user in the same room do not double-count, and so a brief disconnect-reconnect within the grace window does not fire a leave/rejoin pair.
  2. Backs the in-memory presence-roster fallback in live.room’s presence-stream init when platform.presence.list is not wired (the zero-config dev / single-instance path). The user-supplied presence(ctx) payload is held alongside the refcount so a fresh subscriber can reconstruct the existing roster without a cluster-aware backend.

Saturation. When the map reaches the cap, entries with a pending leave timer are evicted first (they were already on their way out). If still full after eviction, the new join is dropped - no entry is created, no 'join' is published - and a one-shot warning surfaces. New joiners in this state are invisible in any subscriber’s roster until existing entries clear.

For multi-instance deploys, wire a cluster-aware platform.presence (e.g. from svelte-adapter-uws-extensions/redis/presence). When platform.presence.list is a function, the in-memory fallback is bypassed entirely and this cap stops mattering.

Production assertions

Critical framework invariants are checked at runtime via lightweight assert(cond, category, context) hooks. Categories cover envelope shape, subscription bookkeeping, push-registry CAD, lock-waiter shape, optimistic-queue pairing, drain-precondition, and settle-entry shape. Production logs [svelte-realtime/assert] records and increments svelte_realtime_assertion_violations_total{category}; test mode throws.

A non-zero counter in production is a framework bug; report against the changelog entry the category maps to.


Reconnection

The client handles disconnections with a three-tier backoff strategy:

  1. Immediate reconnect (0-1s) for transient network blips
  2. Linear backoff (1-5s) for short outages
  3. Exponential backoff with jitter (5-30s) for extended outages

On reconnection, the client sends its last sequence number per topic. The server replays missed messages from the replay buffer. If the buffer has been exceeded, the client receives a full state snapshot instead.

Since 0.5.5, individual streams ALSO survive same-tab unsub / re-subscribe within a resumeGraceMs window (default 60 s). When the last subscriber of a stream unsubs, the WebSocket subscription releases immediately but the in-memory data model (currentValue, last seq, cursor, history) is kept; a new subscribe inside the window re-attaches and gap-fills via the server’s replay buffer instead of cold-starting. This makes {#if active}<Sub />{/if} toggles and browser back/forward feel instant. See configure({ resumeGraceMs }).

For high-concurrency deployments where thousands of clients may reconnect simultaneously (e.g. after a rolling deploy), the exponential backoff with jitter spreads reconnections over a wide window to avoid a connection storm. The replay buffer handles most gaps without touching the database. Delta sync handles medium gaps by sending only changes since the client’s last version. Only clients that have been disconnected longer than the replay buffer’s retention fall back to a full init refetch - and SvelteKit’s built-in SSR deduplication collapses identical anonymous refetches into a single render, so even mass reconnects don’t multiply database load linearly.


Key Design Decisions

No Socket.io dependency. The protocol is a thin JSON layer over raw WebSocket. No fallback polling, no engine.io negotiation. Modern browsers and runtimes all support WebSocket natively.

Server modules are the source of truth. Types flow from src/live/*.ts to the client via the Vite plugin. There is no separate schema file, no code generation step, no OpenAPI spec. Change the server function signature, and the client type errors appear immediately.

Extensions are optional, not required. A single instance with in-memory plugins handles the vast majority of use cases. Redis and Postgres are only needed when you outgrow a single process. The extension APIs mirror the built-in plugin APIs exactly, so migration is a configuration change, not a rewrite.

SSR-safe by design. All $live/* imports work during server-side rendering. The framework returns static initial values on the server and hydrates to live WebSocket data on the client. There is no need to wrap imports in browser checks.

Opt-in per feature, not all-or-nothing. The adapter works as a standard SvelteKit adapter. Live modules are only active where you import them. A page can be purely SSR-rendered while another component on the same page uses live presence - both coexist without conflict. You can write a fully conventional SvelteKit app and only reach for the src/live/ directory when a feature genuinely benefits from shared reactive state.

Was this page helpful?