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 live.throttle() / live.debounce() to control the publish rate server-side rather than increasing the buffer indefinitely. The same applies to maxPayloadLength (default 16KB) - it’s a single config line to raise if your RPCs send larger payloads.
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 | 16 KB | Connection closed (uWS behavior) |
| Max backpressure | 1 MB per connection | Messages silently dropped |
| Upgrade rate limit | 10 per 10s per IP | HTTP 429 |
| Batch size | 50 RPC calls | Client rejects before sending |
| Client send queue | 1000 messages | Oldest item dropped |
| Presence refs | 10,000 entries | Suspended entries evicted, then joins dropped |
| Rate-limit identities | 5,000 buckets | Stale swept, then new identities rejected |
| Throttle/debounce timers | 5,000 entries | New entries bypass timer, publish immediately |
| Topic length | 256 characters | Rejected if longer or contains control characters |
| Replay buffer depth | 1000 per topic | Oldest messages evicted |
All limits are configurable per-deployment.
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.
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?