Tauri + Svelte: Building a Desktop IoT Dashboard That Actually Feels Native
$ npm run tauri dev
# building an app that doesn't eat my RAMThere’s a specific kind of frustration that comes from building an IoT dashboard in Electron.
You ship it. It works. Then you open Task Manager, and your “lightweight monitoring tool” is sitting there at 300MB of RAM, doing absolutely nothing. You’ve wrapped a browser around a browser, and somewhere in that stack, there are three copies of a JavaScript engine just vibing.
I’ve been there. And after building enough of these things — sensor networks, telemetry systems, device fleets — I decided to try a different path: Tauri + Svelte, backed by a WebSocket connection to the real world.
This article is about what I found. Not a toy demo, but a real-time telemetry dashboard that actually feels like a native app — because, for the most part, it is one.
Why Not Electron? (The Short Version)
Electron is fine. I’m not here to dunk on it. But when you’re building tooling for hardware systems where performance and resource usage matter, shipping 200MB of Chromium with your app feels wrong on a philosophical level.
Tauri gives you a native webview (WebKit on macOS/Linux, WebView2 on Windows), a Rust backend, and an app that bundles down to a few megabytes. You still get to write your UI in whatever frontend framework you love — in my case, Svelte — but the heavy lifting is done by Rust.
For IoT dashboards, this is a natural fit. You want:
- Low memory footprint — dashboards run alongside other tools
- Native OS integration — system tray, notifications, window management
- Fast IPC — between your UI and the backend handling device data
- Real-time rendering — metrics that update without choking the thread
Tauri checks all of these. Let’s build it.
The Architecture
Before writing a single line of code, here’s the mental model:
[IoT Devices / Backend Server]
│
WebSocket (ws://)
│
[Tauri Backend - Rust]
├── Manages WS connection
├── Parses telemetry frames
└── Emits events to frontend via tauri::emit
│
[Svelte Frontend]
├── Listens for Tauri events
├── Stores state reactively
└── Renders real-time charts & gaugesThe key design decision here: the Rust backend owns the WebSocket connection, not the frontend. This matters. If you let the browser-side JS manage the WebSocket, you’re at the mercy of the webview’s event loop. Let Rust handle the connection, reconnects, parsing, and error recovery — then push clean, typed data to Svelte via Tauri’s event system.
Setting Up the Project
# Create a new Tauri + Svelte project
npm create tauri-app@latest iot-dashboard
# Choose: Svelte + TypeScript
cd iot-dashboard
npm installYour structure will look like:
iot-dashboard/
├── src/ # Svelte frontend
│ ├── App.svelte
│ └── lib/
├── src-tauri/ # Rust backend
│ ├── src/
│ │ └── main.rs
│ └── Cargo.toml
└── package.jsonThe Rust Backend: WebSocket + Event Emission
This is where it gets interesting. We’ll use tokio-tungstenite for the async WebSocket client, and Tauri’s AppHandle to emit events to the frontend.
First, add the dependencies to src-tauri/Cargo.toml:
[dependencies]
tauri = { version = "2", features = [] }
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures-util = "0.3"Now, define the telemetry payload we expect from the device/server:
// src-tauri/src/main.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelemetryFrame {
pub device_id: String,
pub timestamp: u64,
pub cpu_usage: f32,
pub memory_mb: f32,
pub temperature_c: f32,
pub uptime_secs: u64,
}Next, spawn the WebSocket listener as a background task when the app starts:
use tauri::{AppHandle, Manager};
use tokio_tungstenite::connect_async;
use futures_util::StreamExt;
async fn start_ws_listener(app: AppHandle, ws_url: String) {
loop {
match connect_async(&ws_url).await {
Ok((ws_stream, _)) => {
println!("WebSocket connected to {}", ws_url);
let (_, mut read) = ws_stream.split();
while let Some(msg) = read.next().await {
match msg {
Ok(tokio_tungstenite::tungstenite::Message::Text(text)) => {
if let Ok(frame) = serde_json::from_str::<TelemetryFrame>(&text) {
// Emit to frontend — Svelte will catch this
let _ = app.emit("telemetry", frame);
}
}
Err(e) => {
eprintln!("WebSocket error: {}", e);
break; // trigger reconnect
}
_ => {}
}
}
}
Err(e) => {
eprintln!("Connection failed: {}. Retrying in 3s...", e);
}
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}Wire it into the Tauri setup:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let handle = app.handle().clone();
let ws_url = "ws://your-iot-backend:8080/telemetry".to_string();
tokio::spawn(async move {
start_ws_listener(handle, ws_url).await;
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}The reconnect loop is intentional. IoT environments are unreliable — devices reboot, networks blip. Handling this in Rust means the frontend never has to worry about it.
The Svelte Frontend: Reactive Telemetry
Now the fun part. Svelte’s reactivity model is a natural fit for real-time data — there’s no virtual DOM overhead, and state updates trigger surgical DOM mutations.
First, install the Tauri JS API and a charting library:
npm install @tauri-apps/api chart.jsHere’s the main dashboard component:
<!-- src/lib/Dashboard.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import MetricCard from './MetricCard.svelte';
import TelemetryChart from './TelemetryChart.svelte';
interface TelemetryFrame {
device_id: string;
timestamp: number;
cpu_usage: number;
memory_mb: number;
temperature_c: number;
uptime_secs: number;
}
let latest = $state<TelemetryFrame | null>(null);
let history = $state<TelemetryFrame[]>([]);
let unlisten: (() => void) | null = null;
const MAX_HISTORY = 60; // last 60 frames
onMount(async () => {
unlisten = await listen<TelemetryFrame>('telemetry', (event) => {
latest = event.payload;
history = [...history.slice(-(MAX_HISTORY - 1)), event.payload];
});
});
onDestroy(() => {
unlisten?.();
});
</script>
<main class="dashboard">
{#if latest}
<header class="device-header">
<span class="device-id">{latest.device_id}</span>
<span class="status live">● LIVE</span>
</header>
<div class="metrics-grid">
<MetricCard
label="CPU Usage"
value="{latest.cpu_usage.toFixed(1)}%"
warn={latest.cpu_usage > 80}
/>
<MetricCard
label="Memory"
value="{latest.memory_mb.toFixed(0)} MB"
/>
<MetricCard
label="Temperature"
value="{latest.temperature_c.toFixed(1)}°C"
warn={latest.temperature_c > 70}
/>
<MetricCard
label="Uptime"
value="{formatUptime(latest.uptime_secs)}"
/>
</div>
<TelemetryChart {history} />
{:else}
<div class="waiting">Waiting for telemetry...</div>
{/if}
</main>The listen call from @tauri-apps/api/event is the bridge — it subscribes to the "telemetry" event emitted by our Rust backend. Every time a WebSocket message arrives in Rust, Svelte reacts instantly.
Making It Feel Native
This is the part most tutorials skip. Getting the data flowing is step one. Making the app feel like it belongs on the OS is what separates a real tool from a web page in a window.
1. Custom Title Bar
Nobody wants a giant browser chrome on their monitoring dashboard. In tauri.conf.json:
{
"app": {
"windows": [{
"decorations": false,
"transparent": true
}]
}
}Then build your own draggable title bar in Svelte:
<!-- src/lib/TitleBar.svelte -->
<script lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window';
const appWindow = getCurrentWindow();
</script>
<div
class="titlebar"
data-tauri-drag-region
>
<span class="app-title">IoT Monitor</span>
<div class="window-controls">
<button onclick={() => appWindow.minimize()}>—</button>
<button onclick={() => appWindow.close()}>✕</button>
</div>
</div>data-tauri-drag-region is all you need to make any element draggable like a native title bar.
2. System Tray
For a monitoring tool, staying in the system tray makes more sense than cluttering the taskbar. In src-tauri/src/main.rs:
use tauri::tray::TrayIconBuilder;
use tauri::menu::{Menu, MenuItem};
// inside .setup():
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&quit])?;
TrayIconBuilder::new()
.menu(&menu)
.on_menu_event(|app, event| {
if event.id() == "quit" {
app.exit(0);
}
})
.build(app)?;3. Native Notifications
When a metric crosses a threshold, alert the user like an OS citizen:
use tauri::notification::Notification;
if frame.temperature_c > 75.0 {
Notification::new("com.yourapp.iot-monitor")
.title("Temperature Alert")
.body(format!(
"{} is at {:.1}°C",
frame.device_id, frame.temperature_c
))
.show()
.ok();
}Performance: The Numbers That Surprised Me
After switching from an Electron prototype to Tauri, here’s what I measured on the same machine (MacBook Pro M2) with a 10Hz telemetry stream:
| Electron | Tauri | |
|---|---|---|
| Idle RAM | ~280MB | ~35MB |
| Peak RAM | ~340MB | ~55MB |
| Bundle size | ~180MB | ~8MB |
| Cold start | ~2.1s | ~0.4s |
The bundle size difference alone is hard to overstate if you’re shipping this to technicians or embedding it in a kiosk environment.
What I’d Do Differently
A few things I learned the hard way:
Don’t put the WebSocket in the frontend. It’s tempting — especially if you’re coming from a web background. But the Tauri webview can get suspended on some OSes when the window is minimized, which drops your connection. Rust never sleeps.
Batch your emits. If your devices send data at 50Hz+, emitting every frame to the frontend will cause UI jank. Debounce in Rust — collect frames for 50ms, then emit a batch. Svelte handles the render.
Use Tauri commands for config, events for streaming. Commands (#[tauri::command]) are great for one-off operations like fetching device metadata or saving settings. Events are for the continuous data flow. Keep them separate.
Wrapping Up
Tauri + Svelte is one of those combinations that feels like it was made for this specific use case. You get Rust’s reliability and performance for the parts that matter — connection management, data parsing, system integration — and Svelte’s elegant reactivity for the parts your users actually see.
Is there a learning curve? Yes — especially if Rust is new to you. But the payoff is an app that feels fast, looks native, and doesn’t embarrass you in Task Manager.
The full project I’ve been building on top of this is still in progress, but the core patterns here are stable and production-tested. If you want to dig deeper into any specific part — the charting layer, multi-device support, or the Rust-side message parsing — let me know in the comments.
Build things that just work. Fast and reliable.
$ top -o MEM
PID COMMAND %CPU MEM
1337 iot-monitor 0.2 35MThat’s the kind of performance that makes the switch worth it.
Working on a similar project or have questions about Tauri + Svelte integration? Reach out — qcynaut@gmail.com