Building the Hopbox Cloud Dashboard with Svelte
The Hopbox Cloud dashboard is where our customers and our internal NOC team manage 900+ SD-WAN sites. It shows real-time device status, link quality metrics, configuration management, alerting, and site provisioning — all in a single-page application.
We built it with Svelte (via SvelteKit). This post covers why we chose Svelte, how we structured the application, the real-time patterns we use, and the performance lessons from building a dashboard that needs to handle hundreds of devices updating simultaneously.
Why Svelte
Section titled “Why Svelte”We evaluated React, Vue, and Svelte in late 2024. Here’s what tipped the scale:
Bundle Size
Section titled “Bundle Size”A network management dashboard is used by NOC engineers and customer IT admins — sometimes on mediocre office Wi-Fi, sometimes on a tethered phone connection during a site visit. Bundle size matters.
Svelte compiles components to vanilla JavaScript at build time. There’s no runtime library shipped to the browser. Our production bundle (with all dashboard features) is significantly smaller than an equivalent React application would be:
# Production build outputpnpm build
# Total JS: ~180 KB (gzipped)# Largest chunk: ~45 KB (gzipped)Compare that to a typical React + React Router + state management setup that starts at 40-50 KB gzipped before you write a single line of application code.
Reactivity Model
Section titled “Reactivity Model”Svelte’s reactivity is built into the language. In Svelte 5, the $state and $derived runes make reactive declarations explicit and efficient:
<script> let devices = $state([]); let searchQuery = $state('');
let filteredDevices = $derived( devices.filter(d => d.hostname.toLowerCase().includes(searchQuery.toLowerCase()) || d.siteId.toLowerCase().includes(searchQuery.toLowerCase()) ) );
let onlineCount = $derived( devices.filter(d => d.status === 'online').length );
let offlineDevices = $derived( devices.filter(d => d.status === 'offline') .sort((a, b) => a.lastSeen - b.lastSeen) );</script>No useMemo, no useCallback, no dependency arrays to get wrong. The compiler figures out what depends on what and generates minimal update code.
Learning Curve
Section titled “Learning Curve”We’re a small team. Svelte’s learning curve is gentler than React’s (no hooks mental model, no JSX, closer to standard HTML/CSS/JS). New engineers contribute to the dashboard within days, not weeks.
SvelteKit
Section titled “SvelteKit”SvelteKit gives us file-based routing, server-side rendering for the initial load, API routes for our BFF (Backend for Frontend) layer, and a deployment target for Node.js. It’s the full-stack framework we’d have to assemble from pieces in React-land.
Dashboard Architecture
Section titled “Dashboard Architecture”Browser └── SvelteKit App ├── Pages (file-based routing) │ ├── /dashboard (NOC overview) │ ├── /sites (site list + search) │ ├── /sites/[id] (per-site detail) │ ├── /devices/[id] (per-device detail) │ ├── /alerts (active alerts) │ └── /settings (org settings) ├── API Routes (BFF layer) │ ├── /api/devices (proxy to backend) │ ├── /api/metrics (proxy to Prometheus) │ └── /api/events (SSE endpoint) └── Shared State ├── Device store ├── Alert store └── Auth store
Backend Services ├── Hopbox API (device management, provisioning) ├── Prometheus (metrics) └── Alertmanager (alerts)The SvelteKit app acts as a BFF layer — API routes on the server side proxy requests to backend services, handle authentication, and shape data for the frontend. This keeps API keys and backend URLs out of the browser.
Real-Time Device Status
Section titled “Real-Time Device Status”The core requirement: when a device goes offline, the dashboard should reflect it within seconds, not minutes.
We use Server-Sent Events (SSE) for real-time updates. SSE is simpler than WebSocket for our use case (server-to-client unidirectional updates) and works through corporate proxies that sometimes block WebSocket upgrades.
Server Side (SvelteKit API Route)
Section titled “Server Side (SvelteKit API Route)”import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals }) => { const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder();
// Subscribe to device status changes from backend const unsubscribe = deviceEventBus.subscribe((event) => { const data = JSON.stringify(event); controller.enqueue( encoder.encode(`event: ${event.type}\ndata: ${data}\n\n`) ); });
// Send heartbeat every 30s to keep connection alive const heartbeat = setInterval(() => { controller.enqueue(encoder.encode(': heartbeat\n\n')); }, 30000);
// Cleanup on disconnect return () => { unsubscribe(); clearInterval(heartbeat); }; } });
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' } });};Client Side (Svelte Store)
Section titled “Client Side (Svelte Store)”import { writable } from 'svelte/store';
function createDeviceEventSource() { const { subscribe, update } = writable<Map<string, DeviceStatus>>(new Map());
let eventSource: EventSource | null = null; let reconnectTimer: ReturnType<typeof setTimeout>;
function connect() { eventSource = new EventSource('/api/events');
eventSource.addEventListener('device-status', (e) => { const event = JSON.parse(e.data); update(devices => { devices.set(event.deviceId, event.status); return new Map(devices); // trigger reactivity }); });
eventSource.addEventListener('alert', (e) => { const alert = JSON.parse(e.data); alertStore.add(alert); });
eventSource.onerror = () => { eventSource?.close(); // Reconnect with exponential backoff reconnectTimer = setTimeout(connect, 5000); }; }
connect();
return { subscribe, disconnect: () => { eventSource?.close(); clearTimeout(reconnectTimer); } };}
export const deviceEvents = createDeviceEventSource();Key UI Patterns
Section titled “Key UI Patterns”Device Grid
Section titled “Device Grid”The main dashboard view shows all 900+ devices in a dense grid. Each cell is color-coded by status:
<script> let { devices, onSelect } = $props();</script>
<div class="grid grid-cols-[repeat(auto-fill,minmax(3rem,1fr))] gap-1"> {#each devices as device (device.id)} <button class="h-12 w-12 rounded text-xs font-mono transition-colors {device.status === 'online' ? 'bg-green-500/80 hover:bg-green-500' : device.status === 'degraded' ? 'bg-yellow-500/80 hover:bg-yellow-500' : 'bg-red-500/80 hover:bg-red-500'}" title="{device.hostname} - {device.siteId}" onclick={() => onSelect(device)} > {device.siteId.slice(-4)} </button> {/each}</div>At 900+ devices, rendering this grid needs to be efficient. Svelte’s keyed {#each} block with the device ID ensures minimal DOM updates when individual device statuses change.
Virtual Scrolling for Large Lists
Section titled “Virtual Scrolling for Large Lists”The site list view (a table with sortable columns) uses virtual scrolling to handle 900+ rows without rendering all of them:
<script> let { items, itemHeight = 48, containerHeight = 600 } = $props();
let scrollTop = $state(0);
let visibleRange = $derived(() => { const start = Math.floor(scrollTop / itemHeight); const end = Math.min( start + Math.ceil(containerHeight / itemHeight) + 1, items.length ); return { start, end }; });
let visibleItems = $derived( items.slice(visibleRange().start, visibleRange().end) );
let totalHeight = $derived(items.length * itemHeight); let offsetY = $derived(visibleRange().start * itemHeight);</script>
<div class="overflow-auto" style="height: {containerHeight}px" onscroll={(e) => scrollTop = e.currentTarget.scrollTop}> <div style="height: {totalHeight}px; position: relative;"> <div style="transform: translateY({offsetY}px)"> {#each visibleItems as item (item.id)} <div style="height: {itemHeight}px"> <slot {item} /> </div> {/each} </div> </div></div>This keeps the DOM node count constant regardless of how many devices are in the list.
Site Map
Section titled “Site Map”For geographic visualization, we render device locations on a map of India using Leaflet:
<script> import { onMount } from 'svelte'; import L from 'leaflet';
let { sites } = $props(); let mapContainer; let map;
const statusColors = { online: '#22c55e', degraded: '#eab308', offline: '#ef4444' };
onMount(() => { map = L.map(mapContainer).setView([20.5937, 78.9629], 5);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(map);
return () => map.remove(); });
$effect(() => { if (!map) return;
// Clear existing markers map.eachLayer(l => { if (l instanceof L.CircleMarker) map.removeLayer(l); });
// Add site markers sites.forEach(site => { L.circleMarker([site.lat, site.lng], { radius: 6, fillColor: statusColors[site.status] || '#6b7280', fillOpacity: 0.8, stroke: true, weight: 1, color: '#fff' }) .bindPopup(`<b>${site.name}</b><br>${site.siteId}<br>Status: ${site.status}`) .addTo(map); }); });</script>
<div bind:this={mapContainer} class="h-[500px] w-full rounded-lg"></div>API Design
Section titled “API Design”The backend API follows REST conventions, designed for the dashboard’s access patterns:
GET /api/v1/devices # List all devices (paginated)GET /api/v1/devices/:id # Single device detailGET /api/v1/devices/:id/metrics # Device metrics (proxied from Prometheus)POST /api/v1/devices/:id/actions # Trigger action (reboot, reprovision)
GET /api/v1/sites # List all sitesGET /api/v1/sites/:id # Site detail with all devices
GET /api/v1/alerts # Active alerts (proxied from Alertmanager)POST /api/v1/alerts/:id/ack # Acknowledge alert
GET /api/v1/events # SSE streamFor metrics, the SvelteKit BFF layer queries Prometheus’s HTTP API and transforms the response into a frontend-friendly format:
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url }) => { const range = url.searchParams.get('range') || '1h'; const step = url.searchParams.get('step') || '60s';
const queries = { latency: `hopbox_wan_latency_ms{site="${params.id}"}`, packetLoss: `hopbox_wan_packet_loss_ratio{site="${params.id}"}`, bandwidth: `rate(ifHCInOctets{site="${params.id}"}[5m]) * 8`, };
const results = await Promise.all( Object.entries(queries).map(async ([key, query]) => { const resp = await fetch( `${PROMETHEUS_URL}/api/v1/query_range?` + new URLSearchParams({ query, start: getStartTime(range), end: String(Date.now() / 1000), step }) ); const data = await resp.json(); return [key, data.data.result]; }) );
return Response.json(Object.fromEntries(results));};Performance Optimizations
Section titled “Performance Optimizations”1. Selective Re-rendering
Section titled “1. Selective Re-rendering”When a single device status changes via SSE, only that device’s tile in the grid re-renders. Svelte’s compiled reactivity handles this naturally — there’s no virtual DOM diffing the entire list.
2. Debounced Search
Section titled “2. Debounced Search”The site search filters 900+ sites on every keystroke. We debounce the input to avoid excessive filtering:
<script> let rawQuery = $state(''); let debouncedQuery = $state(''); let timer;
$effect(() => { clearTimeout(timer); timer = setTimeout(() => { debouncedQuery = rawQuery; }, 150); });
let filtered = $derived( sites.filter(s => matchesSite(s, debouncedQuery)) );</script>
<input type="search" bind:value={rawQuery} placeholder="Search sites..." />3. Lazy Loading Charts
Section titled “3. Lazy Loading Charts”Metric charts (Latency, bandwidth, packet loss over time) are heavy components. We only load them when visible using Svelte’s {#await} and dynamic imports:
{#if showMetrics} {#await import('$lib/components/MetricChart.svelte') then { default: MetricChart }} <MetricChart siteId={site.id} metric="latency" range="24h" /> {/await}{/if}4. Data Pagination
Section titled “4. Data Pagination”The device list API supports cursor-based pagination. On initial load, we fetch the first page (100 devices) and load the rest in the background:
async function loadAllDevices() { let cursor: string | null = null; let allDevices: Device[] = [];
do { const resp = await fetch(`/api/v1/devices?limit=100${cursor ? `&cursor=${cursor}` : ''}`); const data = await resp.json(); allDevices = [...allDevices, ...data.devices]; cursor = data.nextCursor; } while (cursor);
return allDevices;}Lessons Learned
Section titled “Lessons Learned”-
SSE > WebSocket for dashboards. Server-Sent Events are simpler to implement, automatically reconnect, work through HTTP/2, and are sufficient for server-to-client updates. We only need WebSocket if the client sends frequent messages to the server.
-
Svelte’s compilation model pays off at scale. With 900+ reactive elements on screen, the absence of a virtual DOM means updates are fast and predictable. We never hit the “too many re-renders” cliff that React developers sometimes encounter.
-
The BFF pattern is essential. Never let the browser talk directly to Prometheus or Alertmanager. The SvelteKit server layer handles auth, rate limiting, query optimization, and data transformation.
-
Virtual scrolling is not optional. Rendering 900+ table rows causes noticeable jank on mid-range hardware. Virtual scrolling keeps the DOM light regardless of dataset size.
-
Invest in loading states. Network management dashboards are accessed during outages — the worst possible time for slow loading. Every component has explicit loading, error, and empty states.
-
Dark mode from day one. NOC engineers stare at this dashboard for hours. Dark mode isn’t a nice-to-have; it’s a health consideration. Tailwind CSS makes it straightforward.
Svelte has been an excellent choice for a data-dense, real-time network management dashboard. The compiled reactivity model, small bundle size, and SvelteKit’s full-stack capabilities let a small team build and maintain a dashboard that performs well with 900+ devices updating in real-time. If you’re building similar operational tooling, we’d recommend giving Svelte a serious look.