Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 056 / 100

Real-time features with SvelteKit in Svelte & SvelteKit

Live updates in the UI.

Expona Admin12 min read

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.

typescript
// 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:

typescript
// 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.

typescript
// 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:

typescript
// 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:

typescript
// 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.

typescript
// 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:

typescript
// 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 onMount cleanup 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 $state rune 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 onMount cleanup 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles