Skip to content

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.

We evaluated React, Vue, and Svelte in late 2024. Here’s what tipped the scale:

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:

dist/_app/immutable/
# Production build output
pnpm 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.

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.

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

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.

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.

src/routes/api/events/+server.ts
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'
}
});
};
src/lib/stores/device-events.ts
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();

The main dashboard view shows all 900+ devices in a dense grid. Each cell is color-coded by status:

DeviceGrid.svelte
<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.

The site list view (a table with sortable columns) uses virtual scrolling to handle 900+ rows without rendering all of them:

VirtualList.svelte
<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.

For geographic visualization, we render device locations on a map of India using Leaflet:

SiteMap.svelte
<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: '&copy; 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>

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 detail
GET /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 sites
GET /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 stream

For metrics, the SvelteKit BFF layer queries Prometheus’s HTTP API and transforms the response into a frontend-friendly format:

src/routes/api/devices/[id]/metrics/+server.ts
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));
};

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.

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

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}

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;
}
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

v1.7.9