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:
| Hook | Fires when | Typical use |
|---|---|---|
upgrade | HTTP upgrade request arrives | Set user identity from cookies or tokens |
open | WebSocket connection established | Join presence, activate plugins |
message | Client sends a frame | Handled automatically by the framework |
subscribe | Client subscribes to a topic | Authorization checks, replay delivery |
unsubscribe | Client leaves a topic | Cleanup |
close | Connection drops | Remove 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.
| Plugin | What it does |
|---|---|
| Replay | Buffers recent messages per topic, delivers on reconnect |
| Presence | Tracks who is connected to each topic, with join/leave events |
| Rate Limiting | Token bucket per user, rejects excess messages |
| Throttle / Debounce | Server-side message coalescing |
| Cursor | Ephemeral position tracking (mouse, selection, drawing) |
| Broadcast Groups | Named groups with membership and roles |
| Typed Channels | Schema-validated topic messages |
| Queue | Ordered processing with backpressure |
| Middleware | Request/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:
- The breaker trips after a configurable number of failures
- All extensions fail fast instead of queueing timeouts
- Fire-and-forget operations (heartbeats, cursor broadcasts) are silently skipped
- A probe request is sent periodically to check if the backend is back
- 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).
| Limit | Default | On breach |
|---|---|---|
| Max message size | 1 MB | Connection closed (uWS behavior) |
| Max backpressure | 1 MB per connection | Messages silently dropped |
| Upgrade rate limit | 10 per 10s per IP | HTTP 429 |
Upgrade admission maxConcurrent | unset (opt-in) | HTTP 503 |
Upgrade admission perTickBudget | unset (opt-in) | Paced via setImmediate |
| Batch size | 50 RPC calls | Client rejects before sending |
| Client send queue | 1000 messages | Oldest item dropped |
Presence refs (MAX_PRESENCE_REF) | 1,000,000 entries | Pending-leave entries evicted first, then new joins dropped with one-shot warn |
Rate-limit identities (_RATE_LIMIT_MAX) | 5,000 buckets | Stale swept, then new identities rejected with RATE_LIMITED |
Throttle/debounce timers (_THROTTLE_DEBOUNCE_MAX) | 5,000 entries | New entries bypass timer, publish immediately (never silently dropped) |
| Topic length | 256 characters | Rejected if longer or contains control characters |
| Replay buffer depth | 1000 per topic | Oldest messages evicted |
| Upload aggregate pre-handler buffer | 64 MB | OVERLOADED |
Upload maxSize | 100 MB | Aborts with OVERLOADED |
Upload maxConcurrentPerSession | 4 | New uploads reject synchronously with OVERLOADED |
| Aggregate sliding-window buckets | 1000 | Module-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:
- 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.
- Backs the in-memory presence-roster fallback in
live.room’s presence-stream init whenplatform.presence.listis not wired (the zero-config dev / single-instance path). The user-suppliedpresence(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:
- Immediate reconnect (0-1s) for transient network blips
- Linear backoff (1-5s) for short outages
- 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?