Building Real-time SvelteKit Apps with Server-Sent Events

Real-time data updates can transform static applications into dynamic, engaging experiences. While WebSockets often get the spotlight for real-time features, Server-Sent Events (SSE) provide a simpler, more reliable alternative for one-way data streaming from server to client. SvelteKit's recent streaming improvements make SSE integration exceptionally smooth.
This tutorial builds a complete real-time analytics dashboard that streams live sensor data from a SvelteKit backend to the frontend. You'll learn to implement SSE endpoints, handle connection management, display real-time charts, and deploy the application with proper error handling.
Link to section: Prerequisites and Project SetupPrerequisites and Project Setup
Before starting, ensure you have Node.js 18+ installed. We'll use SvelteKit 2.19+ and TypeScript for type safety. Create a new SvelteKit project:
npm create svelte@latest realtime-dashboard
cd realtime-dashboard
npm install
Choose "Skeleton project" and enable TypeScript when prompted. Install additional dependencies for our dashboard:
npm install chart.js chartjs-adapter-date-fns date-fns
npm install -D @types/chart.js
The project structure should look like this:
realtime-dashboard/
├── src/
│ ├── lib/
│ ├── routes/
│ └── app.html
├── package.json
└── svelte.config.js
Link to section: Creating the SSE Backend EndpointCreating the SSE Backend Endpoint
SSE requires a persistent HTTP connection that sends data chunks over time. SvelteKit handles this elegantly with streaming responses. Create the SSE endpoint at src/routes/api/sensor-stream/+server.ts
:
import type { RequestHandler } from './$types';
interface SensorReading {
timestamp: string;
temperature: number;
humidity: number;
status: 'normal' | 'warning' | 'critical';
}
export const GET: RequestHandler = async ({ url }) => {
const headers = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
};
const stream = new ReadableStream({
start(controller) {
const sendEvent = (data: SensorReading) => {
const chunk = `data: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(chunk));
};
// Send initial data immediately
sendEvent(generateSensorReading());
// Generate new readings every 2 seconds
const interval = setInterval(() => {
sendEvent(generateSensorReading());
}, 2000);
// Cleanup on client disconnect
return () => {
clearInterval(interval);
controller.close();
};
}
});
return new Response(stream, { headers });
};
function generateSensorReading(): SensorReading {
const temp = 20 + Math.random() * 15; // 20-35°C
const humidity = 40 + Math.random() * 40; // 40-80%
let status: SensorReading['status'] = 'normal';
if (temp > 30 || humidity > 70) status = 'warning';
if (temp > 33 || humidity > 80) status = 'critical';
return {
timestamp: new Date().toISOString(),
temperature: Math.round(temp * 10) / 10,
humidity: Math.round(humidity * 10) / 10,
status
};
}
This endpoint establishes an SSE connection and sends sensor data every two seconds. The ReadableStream
automatically handles client disconnections and cleanup. The Content-Type: text/event-stream
header tells browsers to treat this as an SSE stream.
Link to section: Building the Real-time Dashboard ComponentBuilding the Real-time Dashboard Component
Create the main dashboard component at src/routes/+page.svelte
. This component manages the SSE connection and displays real-time data:
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import Chart from 'chart.js/auto';
import 'chartjs-adapter-date-fns';
interface SensorReading {
timestamp: string;
temperature: number;
humidity: number;
status: 'normal' | 'warning' | 'critical';
}
let currentReading: SensorReading | null = null;
let eventSource: EventSource | null = null;
let connectionStatus = 'disconnected';
let temperatureHistory: { x: Date; y: number }[] = [];
let chartCanvas: HTMLCanvasElement;
let chart: Chart;
const MAX_HISTORY_POINTS = 20;
onMount(() => {
initializeChart();
connectToStream();
});
onDestroy(() => {
if (eventSource) {
eventSource.close();
}
if (chart) {
chart.destroy();
}
});
function connectToStream() {
eventSource = new EventSource('/api/sensor-stream');
eventSource.onopen = () => {
connectionStatus = 'connected';
console.log('SSE connection established');
};
eventSource.onmessage = (event) => {
const reading: SensorReading = JSON.parse(event.data);
currentReading = reading;
updateChart(reading);
};
eventSource.onerror = (error) => {
connectionStatus = 'error';
console.error('SSE connection error:', error);
// Attempt reconnection after 5 seconds
setTimeout(() => {
if (connectionStatus === 'error') {
connectToStream();
}
}, 5000);
};
}
function initializeChart() {
const ctx = chartCanvas.getContext('2d');
if (!ctx) return;
chart = new Chart(ctx, {
type: 'line',
data: {
datasets: [{
label: 'Temperature (°C)',
data: temperatureHistory,
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4
}]
},
options: {
responsive: true,
scales: {
x: {
type: 'time',
time: {
displayFormats: {
second: 'HH:mm:ss'
}
}
},
y: {
beginAtZero: false,
min: 15,
max: 40
}
},
plugins: {
legend: {
display: false
}
}
}
});
}
function updateChart(reading: SensorReading) {
temperatureHistory = [
...temperatureHistory,
{ x: new Date(reading.timestamp), y: reading.temperature }
].slice(-MAX_HISTORY_POINTS);
chart.data.datasets[0].data = temperatureHistory;
chart.update('none'); // No animation for real-time updates
}
function getStatusColor(status: string): string {
switch (status) {
case 'normal': return 'text-green-600';
case 'warning': return 'text-yellow-600';
case 'critical': return 'text-red-600';
default: return 'text-gray-600';
}
}
function reconnect() {
if (eventSource) {
eventSource.close();
}
connectionStatus = 'connecting';
connectToStream();
}
</script>
<div class="min-h-screen bg-gray-50 p-6">
<div class="max-w-4xl mx-auto">
<header class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">
Sensor Dashboard
</h1>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full {connectionStatus === 'connected'
? 'bg-green-500'
: connectionStatus === 'error'
? 'bg-red-500'
: 'bg-yellow-500'}">
</div>
<span class="text-sm text-gray-600">
{connectionStatus === 'connected' ? 'Live' :
connectionStatus === 'error' ? 'Disconnected' : 'Connecting...'}
</span>
</div>
{#if connectionStatus === 'error'}
<button
on:click={reconnect}
class="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
Reconnect
</button>
{/if}
</div>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-white p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Temperature</h3>
<div class="text-3xl font-bold text-blue-600">
{currentReading?.temperature ?? '--'}°C
</div>
</div>
<div class="bg-white p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Humidity</h3>
<div class="text-3xl font-bold text-green-600">
{currentReading?.humidity ?? '--'}%
</div>
</div>
</div>
<div class="bg-white p-6 rounded-lg shadow mb-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Temperature Trend</h3>
<canvas bind:this={chartCanvas} class="w-full h-64"></canvas>
</div>
{#if currentReading}
<div class="bg-white p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold text-gray-900 mb-4">System Status</h3>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">Current Status:</span>
<span class="font-semibold {getStatusColor(currentReading.status)}">
{currentReading.status.toUpperCase()}
</span>
</div>
<div class="text-sm text-gray-500 mt-2">
Last updated: {new Date(currentReading.timestamp).toLocaleTimeString()}
</div>
</div>
{/if}
</div>
</div>

Link to section: Adding Type DefinitionsAdding Type Definitions
Create shared type definitions at src/lib/types.ts
to maintain consistency between frontend and backend:
export interface SensorReading {
timestamp: string;
temperature: number;
humidity: number;
status: 'normal' | 'warning' | 'critical';
}
export interface ConnectionState {
status: 'connected' | 'disconnected' | 'error' | 'connecting';
lastUpdate?: string;
retryCount?: number;
}
export interface ChartDataPoint {
x: Date;
y: number;
}
Update both the server endpoint and component to import these types:
import type { SensorReading } from '$lib/types';
Link to section: Implementing Advanced Error HandlingImplementing Advanced Error Handling
Real-world applications need robust error handling for network interruptions, server restarts, and client-side issues. Enhance the dashboard component with exponential backoff and connection recovery:
// Add to the component script section
let retryCount = 0;
let maxRetries = 5;
let retryDelay = 1000; // Start with 1 second
function connectToStream() {
connectionStatus = 'connecting';
eventSource = new EventSource('/api/sensor-stream');
eventSource.onopen = () => {
connectionStatus = 'connected';
retryCount = 0; // Reset retry count on successful connection
retryDelay = 1000;
console.log('SSE connection established');
};
eventSource.onmessage = (event) => {
try {
const reading: SensorReading = JSON.parse(event.data);
currentReading = reading;
updateChart(reading);
} catch (error) {
console.error('Failed to parse sensor data:', error);
}
};
eventSource.onerror = (error) => {
connectionStatus = 'error';
console.error('SSE connection error:', error);
if (retryCount < maxRetries) {
retryCount++;
const delay = Math.min(retryDelay * Math.pow(2, retryCount - 1), 30000);
console.log(`Reconnecting in ${delay}ms (attempt ${retryCount}/${maxRetries})`);
setTimeout(() => {
if (eventSource) {
eventSource.close();
}
connectToStream();
}, delay);
} else {
console.error('Max retry attempts reached. Manual reconnection required.');
}
};
}
Link to section: Server-Side EnhancementsServer-Side Enhancements
Improve the SSE endpoint to handle different data sources and client management. Update src/routes/api/sensor-stream/+server.ts
:
import type { RequestHandler } from './$types';
interface SensorReading {
timestamp: string;
temperature: number;
humidity: number;
pressure?: number;
status: 'normal' | 'warning' | 'critical';
location: string;
}
// Store active connections for broadcast capability
const connections = new Set<ReadableStreamDefaultController<Uint8Array>>();
export const GET: RequestHandler = async ({ url, request }) => {
const clientId = url.searchParams.get('clientId') || generateClientId();
const headers = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control, Content-Type',
'X-Client-ID': clientId
};
const stream = new ReadableStream({
start(controller) {
connections.add(controller);
const sendEvent = (eventType: string, data: any) => {
try {
const chunk = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(chunk));
} catch (error) {
console.error('Error sending SSE event:', error);
cleanup();
}
};
// Send connection acknowledgment
sendEvent('connected', { clientId, timestamp: new Date().toISOString() });
// Send initial reading
sendEvent('sensor-data', generateSensorReading());
const interval = setInterval(() => {
if (controller.desiredSize === null) {
cleanup();
return;
}
sendEvent('sensor-data', generateSensorReading());
}, 2000);
const cleanup = () => {
clearInterval(interval);
connections.delete(controller);
try {
controller.close();
} catch (error) {
// Controller already closed
}
};
return cleanup;
},
cancel() {
// Client disconnected
console.log(`Client ${clientId} disconnected`);
}
});
return new Response(stream, { headers });
};
function generateClientId(): string {
return Math.random().toString(36).substr(2, 9);
}
function generateSensorReading(): SensorReading {
const baseTemp = 22;
const temp = baseTemp + (Math.sin(Date.now() / 60000) * 5) + (Math.random() * 4 - 2);
const baseHumidity = 55;
const humidity = baseHumidity + (Math.cos(Date.now() / 45000) * 10) + (Math.random() * 10 - 5);
let status: SensorReading['status'] = 'normal';
if (temp > 28 || humidity > 70) status = 'warning';
if (temp > 32 || humidity > 80) status = 'critical';
return {
timestamp: new Date().toISOString(),
temperature: Math.round(temp * 10) / 10,
humidity: Math.round(Math.max(0, Math.min(100, humidity)) * 10) / 10,
pressure: Math.round((1013 + Math.random() * 10 - 5) * 10) / 10,
status,
location: 'Sensor-001'
};
}
// Broadcast function for external triggers
export function broadcastToAllClients(event: string, data: any) {
connections.forEach(controller => {
try {
const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(chunk));
} catch (error) {
connections.delete(controller);
}
});
}
Link to section: Testing and DevelopmentTesting and Development
Start the development server to test the real-time functionality:
npm run dev
Visit http://localhost:5173
and observe the live data updates. Open browser developer tools to monitor the SSE connection in the Network tab. You should see a persistent connection to /api/sensor-stream
with continuous data chunks.
Test error handling by temporarily stopping the development server while the page is open. The dashboard should show "Disconnected" status and attempt automatic reconnection when the server restarts.
For enhanced debugging capabilities in recent SvelteKit versions, enable the experimental tracing features in svelte.config.js
:
export default {
kit: {
experimental: {
tracing: {
server: true
}
}
}
};
Link to section: Production Deployment ConsiderationsProduction Deployment Considerations
When deploying SSE applications, several factors affect reliability and performance. Configure proper headers and connection limits in your production adapter.
For Vercel deployment, create vercel.json
with appropriate settings:
{
"functions": {
"src/routes/api/sensor-stream/+server.ts": {
"maxDuration": 300
}
},
"headers": [
{
"source": "/api/sensor-stream",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
}
]
}
For high-traffic applications, consider implementing connection pooling and rate limiting. Add these enhancements to your SSE endpoint:
// Connection management
const MAX_CONNECTIONS = 1000;
const CONNECTION_TIMEOUT = 300000; // 5 minutes
if (connections.size >= MAX_CONNECTIONS) {
return new Response('Service unavailable', { status: 503 });
}
// Add heartbeat mechanism
const heartbeatInterval = setInterval(() => {
sendEvent('heartbeat', { timestamp: new Date().toISOString() });
}, 30000);
Link to section: Performance OptimizationPerformance Optimization
Large datasets and frequent updates can impact browser performance. Implement data throttling and efficient rendering:
// Debounce chart updates to reduce rendering overhead
let chartUpdateTimeout: NodeJS.Timeout;
function updateChart(reading: SensorReading) {
temperatureHistory = [
...temperatureHistory,
{ x: new Date(reading.timestamp), y: reading.temperature }
].slice(-MAX_HISTORY_POINTS);
// Debounce chart updates for better performance
clearTimeout(chartUpdateTimeout);
chartUpdateTimeout = setTimeout(() => {
chart.data.datasets[0].data = temperatureHistory;
chart.update('none');
}, 100);
}
For applications with multiple real-time data streams, consider using Web Workers to handle data processing off the main thread.
Link to section: Advanced Features and ExtensionsAdvanced Features and Extensions
The foundation built here supports numerous enhancements. Add data persistence by connecting to a database in the SSE endpoint. Implement user authentication to personalize data streams. Create alert systems that trigger notifications when sensor values exceed thresholds.
Consider integrating with WebSocket connections for bidirectional communication when users need to send commands back to the server. The SSE approach works excellently for read-heavy applications while WebSockets better serve interactive scenarios.
This tutorial demonstrates SSE implementation fundamentals in SvelteKit. The streaming architecture scales well and provides reliable real-time updates with automatic reconnection. The approach works particularly well for dashboards, monitoring systems, live feeds, and any application requiring server-to-client data streaming without the complexity of WebSocket management.