Building production systems means handling live data updates without full page reloads. Real-time features with SvelteKit in Svelte & SvelteKit range from simple Server-Sent Events for dashboards to WebSocket-based streams for collaborative tools. We'll walk through each approach with concrete patterns you can ship today.
Server-sent events — Stream from a +server.ts endpoint
Server-Sent Events (SSE) are the simplest real-time transport for one-directional, server-to-client updates. If you need live notifications, stock tickers, or activity feeds, SSE requires less infrastructure than WebSockets and integrates seamlessly into SvelteKit's file-based routing.
In a +server.ts file, you return a Response with text/event-stream content type. The browser's EventSource API automatically reconnects on network failure, making SSE resilient out of the box.
// src/routes/api/notifications/+server.ts
export async function GET({ request }) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Send initial connection message
controller.enqueue(
encoder.encode('data: {"type":"connected"}\n\n')
);
// Simulate sending updates every 3 seconds
const interval = setInterval(() => {
const message = {
type: 'notification',
timestamp: new Date().toISOString(),
text: `Update at ${new Date().toLocaleTimeString()}`
};
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
);
}, 3000);
// Cleanup on client disconnect
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
}
On the client side, use a Svelte component with reactive state to consume the stream:
// src/routes/notifications/+page.svelte
<script>
import { onMount } from 'svelte';
let messages = $state([]);
let connected = $state(false);
onMount(() => {
const eventSource = new EventSource('/api/notifications');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'connected') {
connected = true;
} else if (data.type === 'notification') {
messages = [...messages, data];
// Keep only last 50 messages
if (messages.length > 50) {
messages = messages.slice(-50);
}
}
};
eventSource.onerror = () => {
connected = false;
eventSource.close();
};
return () => eventSource.close();
});
</script>
<div class="notifications">
<p>Status: {connected ? '🟢 Connected' : '🔴 Disconnected'}</p>
<ul>
{#each messages as msg (msg.timestamp)}
<li>{msg.text}</li>
{/each}
</ul>
</div>
<style>
.notifications {
padding: 1rem;
border: 1px solid #ccc;
}
ul {
list-style: none;
padding: 0;
}
li {
padding: 0.5rem;
border-bottom: 1px solid #eee;
}
</style>
Use SSE when your updates flow in one direction and reconnection logic can be simple. For a live dashboard, inventory system, or real-time log viewer, this approach is production-ready with minimal operational overhead.
WebSockets — Use a separate server or platform support
WebSockets enable bidirectional, low-latency communication. They're essential for collaborative editors, multiplayer games, real-time chat, or any system where the client must send frequent messages to the server. SvelteKit doesn't natively handle WebSocket upgrades in production, so you'll either run a separate WebSocket server or use a managed platform.
A common pattern is running Node.js with the ws library alongside SvelteKit, or delegating to a service like Pusher, Ably, or Supabase Realtime.
// WebSocket server (separate process, e.g., ws-server.ts)
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8081 });
const clients = new Set();
wss.on('connection', (ws) => {
console.log('Client connected');
clients.add(ws);
ws.on('message', (data) => {
const message = JSON.parse(data);
console.log('Received:', message);
// Broadcast to all connected clients
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'broadcast',
sender: message.sender,
text: message.text,
timestamp: new Date().toISOString()
}));
}
});
});
ws.on('close', () => {
console.log('Client disconnected');
clients.delete(ws);
});
ws.on('error', (err) => {
console.error('WebSocket error:', err);
});
});
console.log('WebSocket server listening on ws://localhost:8081');
In your Svelte component, connect and manage the WebSocket lifecycle:
// src/routes/chat/+page.svelte
<script>
import { onMount } from 'svelte';
let ws;
let messages = $state([]);
let connected = $state(false);
let inputText = $state('');
let username = $state('User' + Math.floor(Math.random() * 1000));
onMount(() => {
ws = new WebSocket('ws://localhost:8081');
ws.onopen = () => {
connected = true;
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'broadcast') {
messages = [...messages, data];
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
connected = false;
console.log('WebSocket closed');
};
return () => {
if (ws) ws.close();
};
});
function sendMessage() {
if (inputText.trim() && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
sender: username,
text: inputText
}));
inputText = '';
}
}
</script>
<div class="chat-container">
<div class="status">{connected ? '🟢 Connected' : '🔴 Disconnected'}</div>
<div class="messages">
{#each messages as msg (msg.timestamp)}
<div class="message">
<strong>{msg.sender}</strong>: {msg.text}
</div>
{/each}
</div>
<div class="input-area">
<input
bind:value={inputText}
placeholder="Type a message..."
onkeydown={(e) => e.key === 'Enter' && sendMessage()}
/>
<button onclick={sendMessage} disabled={!connected}>Send</button>
</div>
</div>
<style>
.chat-container {
display: flex;
flex-direction: column;
height: 400px;
border: 1px solid #ccc;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.message {
margin-bottom: 0.5rem;
padding: 0.5rem;
background: #f5f5f5;
border-radius: 4px;
}
.input-area {
display: flex;
border-top: 1px solid #ccc;
padding: 1rem;
gap: 0.5rem;
}
input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 0.5rem 1rem;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
In production, consider managed services. Supabase Realtime, for instance, lets you subscribe to database changes without maintaining a separate server. Pusher and Ably provide global edge networks and guaranteed delivery, reducing your operational burden.
Reactive state — Update $state from incoming messages
Svelte 5's runes—particularly $state—make it trivial to keep your UI in sync with incoming real-time data. The reactivity system automatically tracks dependencies, so updating a state variable immediately triggers a re-render.
For complex scenarios like a live inventory dashboard or a collaborative document editor, you'll want to structure state carefully to avoid unnecessary re-renders:
// src/routes/inventory/+page.svelte
<script>
import { onMount } from 'svelte';
let inventory = $state({
items: [],
lastUpdate: null,
totalValue: 0
});
let isLoading = $state(true);
let error = $state(null);
onMount(() => {
const eventSource = new EventSource('/api/inventory-stream');
eventSource.onmessage = (event) => {
const update = JSON.parse(event.data);
if (update.type === 'initial') {
inventory.items = update.items;
inventory.lastUpdate = new Date();
isLoading = false;
} else if (update.type === 'item-updated') {
// Update only the changed item
const index = inventory.items.findIndex(
(item) => item.id === update.item.id
);
if (index !== -1) {
inventory.items[index] = update.item;
}
inventory.lastUpdate = new Date();
} else if (update.type === 'item-removed') {
inventory.items = inventory.items.filter(
(item) => item.id !== update.itemId
);
inventory.lastUpdate = new Date();
}
// Recalculate total value
inventory.totalValue = inventory.items.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
};
eventSource.onerror = (err) => {
error = 'Connection lost';
isLoading = false;
eventSource.close();
};
return () => eventSource.close();
});
</script>
<div class="dashboard">
{#if isLoading}
<p>Loading inventory...</p>
{:else if error}
<p class="error">{error}</p>
{:else}
<div class="summary">
<p>Total Value: ${inventory.totalValue.toFixed(2)}</p>
<p>Last Update: {inventory.lastUpdate?.toLocaleTimeString()}</p>
</div>
<table>
<thead>
<tr>
<th>SKU</th>
<th>Quantity</th>
<th>Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{#each inventory.items as item (item.id)}
<tr>
<td>{item.sku}</td>
<td>{item.quantity}</td>
<td>${item.price.toFixed(2)}</td>
<td>${(item.price * item.quantity).toFixed(2)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style>
.dashboard {
padding: 2rem;
}
.summary {
margin-bottom: 2rem;
padding: 1rem;
background: #f9f9f9;
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #f5f5f5;
font-weight: bold;
}
.error {
color: #d32f2f;
}
</style>
The key is to update state in a granular way. Instead of replacing the entire array, update individual items. This keeps the reactivity graph small and re-renders fast. Svelte's fine-grained reactivity ensures only affected DOM nodes update.
Reconnect — Back off and retry on disconnects
Production networks are unreliable. A user's browser tab might lose connectivity, the server might restart, or the connection might time out silently. You need robust reconnection logic with exponential backoff to avoid hammering your server.
// src/lib/realtime.ts
export interface RealtimeConfig {
url: string;
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
}
export class RealtimeClient {
private eventSource?: EventSource;
private retryCount = 0;
private retryTimeout?: number;
constructor(
private config: RealtimeConfig,
private onMessage: (data: any) => void,
private onStateChange: (connected: boolean) => void
) {
this.config.maxRetries = config.maxRetries ?? 10;
this.config.initialDelayMs = config.initialDelayMs ?? 1000;
this.config.maxDelayMs = config.maxDelayMs ?? 30000;
}
connect() {
this.eventSource = new EventSource(this.config.url);
this.eventSource.onopen = () => {
this.retryCount = 0;
this.onStateChange(true);
};
this.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
this.onMessage(data);
};
this.eventSource.onerror = () => {
this.eventSource?.close();
this.handleDisconnect();
};
}
private handleDisconnect() {
this.onStateChange(false);
if (this.retryCount >= this.config.maxRetries!) {
console.error(
`Max retries (${this.config.maxRetries}) exceeded, giving up`
);
return;
}
// Exponential backoff: delay = initialDelay * 2^retryCount, capped at maxDelay
const delay = Math.min(
this.config.initialDelayMs! * Math.pow(2, this.retryCount),
this.config.maxDelayMs!
);
this.retryCount++;
console.log(
`Reconnecting in ${delay}ms (attempt ${this.retryCount}/${this.config.maxRetries})`
);
this.retryTimeout = window.setTimeout(() => {
this.connect();
}, delay);
}
disconnect() {
this.eventSource?.close();
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
}
}
}
Use it in a component:
// src/routes/dashboard/+page.svelte
<script>
import { onMount } from 'svelte';
import { RealtimeClient } from '$lib/realtime';
let connected = $state(false);
let data = $state([]);
onMount(() => {
const client = new RealtimeClient(
{ url: '/api/data-stream' },
(update) => {
data = [...data, update];
},
(isConnected) => {
connected = isConnected;
}
);
client.connect();
return () => client.disconnect();
});
</script>
<div>
<p>Status: {connected ? '🟢 Connected' : '🔴 Reconnecting...'}</p>
<ul>
{#each data as item}
<li>{item}</li>
{/each}
</ul>
</div>
This approach gives you resilient real-time features that gracefully handle network outages without user intervention. The exponential backoff ensures you don't overwhelm the server if it's temporarily down.
Common mistakes
- Forgetting to clean up streams. Always close EventSource or WebSocket connections in the
onMountcleanup function. Otherwise, you'll accumulate connections and leak memory. - No backoff on reconnect. Retrying immediately after every failure can turn a temporary outage into a denial-of-service on your own server. Always implement exponential backoff.
- Mutating state directly in loops. Instead of pushing to arrays in a loop (e.g.,
messages.push(...)), reassign the entire array with spread syntax or.map(). This ensures Svelte's reactivity picks up the change. - Ignoring message ordering. With bidirectional communication, messages can arrive out of order. Use timestamps or sequence numbers to maintain consistency, especially in collaborative tools.
- Not testing network failures. Use browser DevTools to throttle or disable the network and verify your reconnection logic actually works. Simulate server restarts in staging.
Key takeaways
- Server-Sent Events are perfect for one-directional, server-to-client updates; they require less infrastructure than WebSockets and reconnect automatically.
- WebSockets enable bidirectional communication but need a separate server or managed platform; they're essential for chat, collaboration, and low-latency interactions.
- Use Svelte's
$staterune to keep your UI synchronized with incoming data; update state granularly rather than replacing entire objects to avoid unnecessary re-renders. - Always implement exponential backoff for reconnection logic; uncapped retries can overwhelm your server and worsen outages.
- Clean up connections in
onMountcleanup functions to prevent memory leaks and connection storms. - Test network failures and timeouts in staging before shipping; real-world conditions are messier than happy-path scenarios.






Comments
Loading comments…