Desktop Apps Without the Chromium Tax: Rust, Tauri, and Polysong
- rust
- tauri
- desktop
- sqlite
Electron ships a full browser with every app. Tauri uses the OS webview and a Rust core, cutting bundle size by 10x and memory by half. Here is what that trade actually costs you.
I wanted a local-first music library manager. Something that scans a folder tree, reads audio tags, stores everything in SQLite, and never touches the network. Polysong is that app. I built it with Rust and Tauri, and after six months of daily use I have a clear picture of where this stack earns its reputation and where it quietly hands you a problem.
The Core Architecture
Tauri splits the app into two processes. The backend is a Rust binary compiled with your business logic. The frontend is any web stack you like, rendered inside the OS-native webview: WebKit on macOS, WebView2 (Chromium-based) on Windows, and WebKitGTK on Linux. These two sides talk through a typed command bridge.
The webview does not ship with your app. The user already has it. On macOS and modern Windows it comes with the OS. That single fact is why a Tauri app installer can weigh 4-8 MB while an equivalent Electron app weighs 80-150 MB. The Rust binary is compiled ahead of time, so there is no Node.js runtime either.

The Command Bridge
Every call from the frontend to Rust goes through a #[tauri::command] function. Arguments are deserialized from JSON, the function runs, and the return value is serialized back. The types are explicit on both sides. If you misname a field or pass the wrong shape, you get a runtime error in the Rust layer rather than silent data corruption.
Here is the core command Polysong uses to query the library. It takes a search string and a sort key, hits SQLite via rusqlite, and returns a vector of track records.
use serde::{Deserialize, Serialize};
use tauri::State;
use rusqlite::params;
use crate::db::DbConn;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TrackRecord {
pub id: i64,
pub title: String,
pub artist: String,
pub album: String,
pub duration_secs: f64,
pub file_path: String,
}
#[tauri::command]
pub async fn search_tracks(
query: String,
sort_by: Option<String>,
db: State<'_, DbConn>,
) -> Result<Vec<TrackRecord>, String> {
let conn = db.lock().map_err(|e| e.to_string())?;
let sort_col = match sort_by.as_deref() {
Some("artist") => "artist, title",
Some("album") => "album, track_num",
_ => "title",
};
let pattern = format!("%{}%", query.to_lowercase());
let sql = format!(
"SELECT id, title, artist, album, duration_secs, file_path
FROM tracks
WHERE lower(title) LIKE ?1
OR lower(artist) LIKE ?1
OR lower(album) LIKE ?1
ORDER BY {sort_col}
LIMIT 500"
);
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![pattern], |row| {
Ok(TrackRecord {
id: row.get(0)?,
title: row.get(1)?,
artist: row.get(2)?,
album: row.get(3)?,
duration_secs: row.get(4)?,
file_path: row.get(5)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}On the frontend side, the call is a single invoke from @tauri-apps/api/core. TypeScript types are generated automatically if you use the Tauri CLI plugin generate bindings flag, or you can write them by hand for small surfaces.
import { invoke } from '@tauri-apps/api/core';
interface TrackRecord {
id: number;
title: string;
artist: string;
album: string;
duration_secs: number;
file_path: string;
}
export async function searchTracks(
query: string,
sortBy?: 'title' | 'artist' | 'album',
): Promise<TrackRecord[]> {
return invoke<TrackRecord[]>('search_tracks', {
query,
sort_by: sortBy ?? null,
});
}Configuration: tauri.conf.json
Most Tauri behavior is controlled by tauri.conf.json. For Polysong I lock down the capability surface aggressively: no shell access from the webview, filesystem access limited to the music library directory, no arbitrary HTTP from the frontend. This is one of the best arguments for Tauri over Electron from a security perspective. The default posture is deny-all; you add capabilities explicitly.
{
"productName": "Polysong",
"version": "0.3.0",
"identifier": "io.polysong.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173"
},
"app": {
"windows": [
{
"title": "Polysong",
"width": 1200,
"height": 780,
"minWidth": 800,
"minHeight": 560,
"decorations": true,
"resizable": true
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.icns", "icons/icon.ico"]
}
}The Real Tradeoff: Platform Webview Divergence
This is where Tauri earns its asterisk. On macOS and Linux you get WebKit. On Windows you get WebView2, which is Chromium-based. They are not the same engine, and the differences surface in CSS and JavaScript behavior, especially around audio, video, and some layout edge cases.
For Polysong this bit me in the audio pipeline. The Web Audio API behaves slightly differently between WebKit and WebView2 for certain buffer sizes. I ended up moving audio playback entirely into the Rust layer using rodio and exposing a small command surface for play, pause, seek, and volume. That was arguably the right call anyway since Rust gives me gapless playback and ReplayGain with no extra gymnastics.
- Test on all three platforms before committing to any CSS animation or Web Audio code
- Anything performance-sensitive or platform-specific belongs in Rust, not the webview
- CSS grid and flexbox are consistent enough across all three that you can write it once
- Fonts render differently between WebKit and WebView2; use
font-synthesis: noneand serve your own fonts - WebView2 on Windows requires the WebView2 runtime to be installed; modern Windows 11 ships it, older systems may not
The memory story is genuinely better than Electron. A fresh Polysong window with a 40k-track library loaded sits around 90 MB RSS. The equivalent Electron app I prototyped earlier peaked at 250 MB doing the same work. Most of the Tauri footprint is the webview renderer itself; the Rust binary is negligible.
Running This Stack with an AI Coding Agent
I work with an AI coding agent (Claude Code) on Polysong regularly. Rust plus Tauri plus SQLite is not a small surface area. The agent has strong general Rust knowledge but Tauri 2.x has breaking changes from 1.x, the capability system is new, and my project conventions are specific. Without persistent context the agent regresses: it writes Tauri 1.x allowlist syntax, forgets that audio lives in Rust, suggests the wrong crate versions.
The fix is an AGENTS.md file at the project root. It is checked into git, always present, and I update it every time I make a structural decision. The agent reads it at the start of each session and operates with the full current picture of the project.

Here is the actual AGENTS.md from Polysong, trimmed slightly for length:
# Polysong – Agent Context
## Stack
- Rust (stable, 1.78+) + Tauri 2.x + SQLite via rusqlite 0.31
- Frontend: SvelteKit (static adapter, no SSR), TypeScript, Tailwind 4
- Audio playback: Rust only, via rodio 0.18. Do NOT use Web Audio API for playback.
- DB migrations: barrel-migrations crate, files in src-tauri/migrations/
## Tauri version note
This project uses **Tauri 2.x**. The old allowlist is GONE.
Permissions live in src-tauri/capabilities/. Each capability file is JSON.
Never write tauri::Builder allowlist config — it will not compile.
## Commands
- `cargo tauri dev` – dev mode with hot reload
- `cargo tauri build` – production bundle
- `cargo test` – Rust unit tests (run from src-tauri/)
- `pnpm dev` – frontend only (not connected to Tauri backend)
## Architecture decisions
- All file system access goes through Rust commands. The frontend never touches the FS directly.
- Audio: playback, seek, and volume are Rust commands. The frontend sends intents, gets state back.
- Search is SQLite FTS5 for albums/artists, LIKE for track titles (FTS5 overkill for title search).
- Window state (last size/position) is saved via tauri-plugin-window-state.
## Known gotchas
- WebKit (macOS/Linux) and WebView2 (Windows) differ on Web Audio. Keep audio in Rust.
- rusqlite must be compiled with the `bundled` feature on Windows or linking fails.
- Font rendering differs between platforms. Use `font-synthesis: none` and local fonts.
- The migrations barrel runner requires migration files numbered 001_, 002_ etc., no gaps.
## File layout
```
src-tauri/
src/
commands/ # Tauri command handlers
db/ # Connection pool and query helpers
audio/ # rodio playback engine
migrations/ # SQL migration files
capabilities/ # Tauri 2 permission manifests
src/ # SvelteKit frontend
```That file has eliminated most of the context-loss regressions. When I open a new agent session, I say "read AGENTS.md first" and the agent immediately operates in the right frame: correct Tauri version, correct crate versions, correct architectural constraints. It stops suggesting Web Audio for playback. It writes capability manifests instead of allowlists. The quality of the first attempt on any task goes up significantly.
Is Tauri Worth It
For Polysong: yes, clearly. The bundle is small, memory is low, and pushing I/O-heavy work into Rust is pleasant rather than painful. The SQLite integration via rusqlite is the best I have used in any language. Gapless audio playback in rodio took an afternoon.
The platform webview divergence is real but manageable if you follow one rule: anything that touches OS primitives, audio, video, file I/O, or performance-sensitive computation goes in Rust. The webview is a rendering surface. Keep it that way and the cross-platform story holds up.
If you need rich web content that truly must behave identically everywhere, Electron is still the pragmatic choice. But if you are building a utility app with real system integration, Tauri gives you a smaller, faster, more defensible result. The Rust learning curve is the honest cost. It is not shallow, but it pays for itself in a codebase where correctness actually matters.
Tauri does not make desktop development easier. It makes it smaller, faster, and more honest about where the work belongs.