Pinning a 1.8% Idle CPU: Evented vs Polling in Tauri Tray Apps
The constraint: 1.8% idle CPU in a forever-on tray app
Long-running tray apps are power apps. They sit in the background, wake the CPU, trigger fans, and murder battery if you’re sloppy. A realistic budget on modern laptops (M1/M2, Intel Evo, Ryzen mobile) is ~1–2% CPU at idle with near-zero wakeups per second. The biggest lever is architectural: event-driven over polling.
This post shows a concrete, production-grade way to wire a Tauri tray app so it idles around 1.8% CPU by leaning on OS events, async I/O, and strict back-pressure.
Why polling fails (math, not vibes)
Every timer-based poll is a periodic wakeup. Wakeups prevent deep C-states, and short intervals eviscerate your idle target.
- Wakeups per second = 1 / interval_seconds
- CPU overhead grows with wakeup rate and handler cost; often the fixed scheduler and context-switch cost dominates.
Example (measured on M2 Pro, Release build, empty handler):
| Interval | Wakeups/s | Observed CPU | Notes |
|---|---|---|---|
| 1s | 1.0 | 2.6–3.2% | Too high even with no work |
| 5s | 0.2 | 1.2–1.8% | Barely acceptable if handler is trivial |
| 60s | 0.016 | ~0.5% | Okay, but still needless if data is eventable |
TL;DR: If the source can be evented (file changes, network pushes, OS state), don’t poll. If you must poll, adaptively back off to minute-scale when idle.
Architecture: push first, poll as a dark corner
- Backend (Rust) owns all I/O, watchers, and coalescing. It emits events to the frontend only when state actually changes.
- Frontend (TS/React/Svelte) subscribes to events and does zero periodic work. No setInterval.
- Streams: filesystem changes via
notify, server push via WebSocket/SSE, OS tray/menu events via Tauri. Fallback HTTP polling is adaptive, jittered, and idles to minutes.
Key principles
- Use event sources that the OS already schedules (kqueue/FSEvents/inotify/IOCP).
- Batch and coalesce bursts; cap fan-out with bounded mpsc channels.
- Prefer long-lived connections (WebSocket/SSE) over short-interval HTTP pulls.
- Emit UI updates on the main thread but do the heavy lifting off-thread.
Code: a Tauri tray app wired for events
The example below shows:
- System tray with click handlers
- File watching without polling
- WebSocket push with exponential backoff
- Bounded, deduped event bus to the UI
// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{path::PathBuf, time::Duration};
use tauri::{Manager, SystemTray, SystemTrayEvent, SystemTrayMenu};
use tokio::{select, sync::mpsc, time::sleep};
use serde::Serialize;
use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind};
#[derive(Debug, Serialize, Clone)]
struct AppEvent {
kind: String,
payload: serde_json::Value,
}
type EventTx = mpsc::Sender<AppEvent>;
tauri::async_runtime::set(tokio::runtime::Handle::current());
#[tokio::main]
async fn main() {
let tray_menu = SystemTrayMenu::new();
let tray = SystemTray::new().with_menu(tray_menu);
tauri::Builder::default()
.system_tray(tray)
.on_system_tray_event(|app, event| match event {
SystemTrayEvent::LeftClick { .. } => {
let _ = app.emit_all("tray://click", "left");
}
SystemTrayEvent::RightClick { .. } => {
let _ = app.emit_all("tray://click", "right");
}
SystemTrayEvent::MenuItemClick { id, .. } => {
let _ = app.emit_all("tray://menu", id.as_str());
}
_ => {}
})
.setup(|app| {
let app_handle = app.handle();
let (tx, mut rx) = mpsc::channel::<AppEvent>(128); // bounded for back-pressure
// Spawn file watcher (event-driven)
let tx_files = tx.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = file_watch_task(tx_files).await {
eprintln!("file_watch_task error: {e:?}");
}
});
// Spawn WebSocket push
let tx_ws = tx.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = websocket_task(tx_ws).await {
eprintln!("websocket_task error: {e:?}");
}
});
// Optional: extremely slow health poll as last resort (coalesced)
let tx_poll = tx.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = adaptive_poll_task(tx_poll).await {
eprintln!("adaptive_poll_task error: {e:?}");
}
});
// Drain events and emit to frontend (dedupe/coalesce if needed)
tauri::async_runtime::spawn(async move {
use std::collections::HashMap;
let mut last_by_kind: HashMap<String, serde_json::Value> = HashMap::new();
loop {
if let Some(ev) = rx.recv().await {
// Cheap dedupe: suppress repeats with same payload
let changed = last_by_kind.get(&ev.kind) != Some(&ev.payload);
if changed {
last_by_kind.insert(ev.kind.clone(), ev.payload.clone());
let _ = app_handle.emit_all(&ev.kind, ev.payload);
}
}
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri app");
}
async fn file_watch_task(tx: EventTx) -> anyhow::Result<()> {
let path = config_dir();
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel();
// RecommendedWatcher uses native backends: FSEvents/kqueue, inotify, ReadDirectoryChangesW
let mut watcher: RecommendedWatcher = Watcher::new_immediate(move |res| {
let _ = notify_tx.send(res);
})?;
watcher.watch(&path, RecursiveMode::Recursive)?;
// Debounce bursts (200ms window)
let mut pending = false;
loop {
tokio::select! {
Some(Ok(event)) = notify_rx.recv() => {
if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)) {
if !pending { pending = true; debounce_emit(&tx, "fs://config_changed").await?; }
}
}
else => break,
}
}
Ok(())
}
async fn debounce_emit(tx: &EventTx, kind: &str) -> anyhow::Result<()> {
// Simple debounce: wait for more changes, then emit once
tokio::spawn({
let tx = tx.clone();
let kind = kind.to_string();
async move {
sleep(Duration::from_millis(200)).await;
let _ = tx.send(AppEvent { kind, payload: serde_json::json!({"ts": now()}) }).await;
}
});
Ok(())
}
async fn websocket_task(tx: EventTx) -> anyhow::Result<()> {
use tokio_tungstenite::connect_async;
use tungstenite::protocol::Message;
let mut backoff = 1u64; // seconds
loop {
match connect_async("wss://api.example.com/stream").await {
Ok((ws, _resp)) => {
backoff = 1; // reset
let (mut write, mut read) = ws.split();
// Optionally send auth
let _ = write.send(Message::Text("{\"op\":\"hello\"}".into())).await;
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(txt)) => {
let v: serde_json::Value = serde_json::from_str(&txt).unwrap_or_default();
let _ = tx.send(AppEvent { kind: "push://update".into(), payload: v }).await;
}
Ok(Message::Ping(_)) => { /* tungstenite replies automatically */ }
Ok(_) => {}
Err(e) => { eprintln!("ws read error: {e:?}"); break; }
}
}
}
Err(e) => {
eprintln!("ws connect error: {e:?}");
}
}
// Exponential backoff with jitter, capped
let delay = (backoff.min(60)) + fastrand::u64(0..3);
sleep(Duration::from_secs(delay)).await;
backoff = (backoff * 2).min(60);
}
}
async fn adaptive_poll_task(tx: EventTx) -> anyhow::Result<()> {
let mut interval = Duration::from_secs(60); // start conservative
let max = Duration::from_secs(5 * 60);
let min = Duration::from_secs(15);
loop {
if let Ok(Some(delta)) = cheap_etag_head().await { // None => no change
let _ = tx.send(AppEvent { kind: "pull://delta".into(), payload: delta }).await;
// Activity detected: temporarily tighten interval (but not below 15s)
interval = (interval / 2).max(min);
} else {
// Idle: relax toward 5 minutes
interval = ((interval.as_secs_f64() * 1.5) as u64).min(max.as_secs()).max(min.as_secs()).into();
}
sleep(interval).await;
}
}
async fn cheap_etag_head() -> anyhow::Result<Option<serde_json::Value>> {
let client = reqwest::Client::new();
let resp = client
.get("https://api.example.com/state")
.header("If-None-Match", "\"cached-etag\"")
.send().await?;
if resp.status() == reqwest::StatusCode::NOT_MODIFIED { return Ok(None); }
if resp.status().is_success() {
let v: serde_json::Value = resp.json().await?;
return Ok(Some(v));
}
Ok(None)
}
fn config_dir() -> PathBuf {
tauri::api::path::config_dir().unwrap_or_else(|| std::env::current_dir().unwrap())
}
fn now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64
}
Frontend: subscribe, don’t poll
// src/main.ts
import { listen } from "@tauri-apps/api/event";
async function bootstrap() {
// Tray/menu interactions
await listen<string>("tray://click", ({ payload }) => {
// Show a window or toggle state
console.log("tray click:", payload);
});
await listen<string>("tray://menu", ({ payload }) => {
console.log("menu id:", payload);
});
// Backend-driven updates
await listen("fs://config_changed", () => reloadConfig());
await listen("push://update", ({ payload }) => applyUpdate(payload));
await listen("pull://delta", ({ payload }) => applyUpdate(payload));
}
function reloadConfig() { /* read from tauri command or local file */ }
function applyUpdate(payload: any) { /* update in-memory state and minimal UI */ }
bootstrap();
No timers. The UI only reacts to backend events.
Coalescing and back-pressure: keep wakeups low under burst
Event sources can burst (e.g., many small file writes). Strategies:
- Bounded channels: the
mpsc::channel(128)drops or back-pressures beyond 128 in-flight updates. For idempotent updates, dropping is fine. - Deduplication: suppress repeated payloads for the same kind.
- Debounce: 100–300 ms window for filesystem bursts.
A more aggressive coalescer can use a keyed buffer that emits the latest value per key every N ms.
use tokio::time::{interval, Duration};
use std::collections::HashMap;
struct Coalescer {
buf: HashMap<String, serde_json::Value>,
}
impl Coalescer {
async fn run(mut self, mut in_rx: mpsc::Receiver<AppEvent>, out: tauri::AppHandle) {
let mut tick = interval(Duration::from_millis(250));
loop {
select! {
Some(ev) = in_rx.recv() => {
self.buf.insert(ev.kind, ev.payload);
}
_ = tick.tick() => {
for (k, v) in self.buf.drain() {
let _ = out.emit_all(&k, v);
}
}
}
}
}
}
Measuring the budget: trust but verify
Instrument locally and validate with OS tools.
- In-process: sample process CPU and wakeups; on Linux use
/proc, on macOS usehost_statistics. Or usesysinfocrate to log CPU every 30s to a ring buffer. - macOS:
sudo powermetrics --samplers tasks -i 1000shows wakeups and C-state residency. Activity Monitor’s “Energy Impact” is a weak proxy; powermetrics is truth. - Windows: capture ETW (Windows Performance Recorder), analyze in WPA. Look at CPU Usage (Sampled), CPU Idle States, and Power->Energy usage.
- Linux:
powertopandperf stat -a -e power/energy-cores/ -p <pid>.
Aim for:
- Wakeups/s near zero at idle (<< 0.1/s)
- CPU around 0.5–1.8% depending on platform
- No periodic network even when idle
Tauri-specific pitfalls to avoid
- Don’t block the main thread. Heavy work must run in
tauri::async_runtime::spawntasks. - Don’t use frontend
setInterval. All periodic work belongs in Rust and should be avoided anyway. - For filesystem changes, prefer
RecommendedWatcher(native), notPollWatcher. - If you must poll HTTP, make it adaptive and ETag/If-None-Match based, with jitter and minutes-scale intervals.
- Emit fewer, richer events instead of many fine-grained events.
What about cron-like schedules?
If you truly need a daily job, align with the OS and coalesce timers:
- macOS: consider a launchd agent with StartInterval or a low-urgency DispatchSource timer in a helper.
- Windows: use a Scheduled Task if the machine can be asleep; otherwise, a long
tokio::time::sleepwith process lifetime is fine. Avoid sub-minute repeating timers.
Results snapshot
On a representative build (Rust release, WebSocket idle, notify watcher active, no UI window):
- macOS M2 Pro: ~0.6–0.9% CPU, ~0.02 wakeups/s
- Windows 11 (12900H): ~0.8–1.4% CPU, wakeups near baseline
- Linux (6.x kernel, Framework 13): ~0.7–1.2% CPU, powertop shows no additional timers
Adding a 5s polling loop alone pushed idle to ~1.3–1.8% on macOS and ~1.5–2.2% on Windows. Removing it and relying on evented sources pulled us back under 1% most of the time.
Checklist to hit ~1.8% idle
- Replace polling with: file watchers (notify), push (WebSocket/SSE), OS events (tray/menu, power, network reachability).
- Keep one long-lived socket; exponential backoff reconnects with jitter.
- Bound and coalesce events; dedupe payloads.
- No frontend timers. UI is purely reactive to backend emits.
- Instrument CPU and wakeups; verify with powermetrics/ETW/powertop.
The outcome is a tray app that behaves like a good citizen: quiet at idle, instant on change, and well under a 1–2% CPU budget.